Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Recent design context, bugs, and convention rationale: see the bug-audit finding
- **`FastAPIConfig.application` uses an `UnsetType` sentinel**: shared in `lite_bootstrap/types.py` as `UnsetType` + `UNSET` (singleton). `FastAPIConfig.__post_init__` checks `isinstance(self.application, UnsetType)` and replaces with a constructed `FastAPI()` via `object.__setattr__` (config stays frozen for user-facing immutability). A one-line comment in `__post_init__` documents the freeze bypass.
- **Instrument registry**: `BaseBootstrapper` holds a list of instrument instances; it calls `bootstrap()` on each in order and `teardown()` in reverse during shutdown.
- **Idempotent teardown**: `BaseBootstrapper.teardown()` returns immediately if `not self.is_bootstrapped`. Cached runtime state in `LoggingInstrument` and `OpenTelemetryInstrument` is reset inside `try/finally` so a raised shutdown leaves no stale references.
- **Logging ↔ Sentry integration**: `logging_instrument.py` injects structlog context into Sentry events. `sentry_instrument.py` chains `before_send` callbacks via `wrap_before_send_callbacks()`. The `skip_sentry` flag in log context suppresses events; the flag is also stripped from the Sentry context payload (added to `IGNORED_STRUCTLOG_ATTRIBUTES`).
- **Logging ↔ Sentry integration**: `logging_instrument.py` renders structlog lines to JSON; the seam is `StructuredLogPayload.parse` in `logging_factory.py`, which reconstructs a line into `message`/`extra`/`skip_sentry` and owns the meta-key vocabulary (`STRUCTLOG_META_KEYS`, stripped from `extra`). `sentry_instrument.py`'s `enrich_sentry_event_from_structlog_log` (chained after the user's `before_send` via `wrap_before_send_callbacks()`) only maps the parsed payload onto the Sentry event — a truthy `skip_sentry` suppresses the event (checked before the message-presence test), else it lifts `message` and attaches `extra` under `contexts.structlog`. `IGNORED_STRUCTLOG_ATTRIBUTES` stays as a back-compat alias of `STRUCTLOG_META_KEYS`. The value object holds no Sentry-event-shape knowledge; adding a custom top-level meta-processor to the logging chain means extending `STRUCTLOG_META_KEYS` (cross-referenced at `tracer_injection`).
- **OTel ↔ Logging integration**: The logging instrument injects span/trace IDs from the active OpenTelemetry context into every log record.
- **`OpenTelemetryInstrument` is single-instance per process**: `bootstrap()` calls `opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (subsequent calls log `"Overriding of current TracerProvider is not allowed"` and have no effect). `teardown()` calls `shutdown()` on the provider (flushes batched spans, closes exporters) but cannot reset the process-global pointer. Construct one `OpenTelemetryInstrument` per process; do not bootstrap a second instance. Verified against `opentelemetry/trace/__init__.py:548-556`.

Expand All @@ -63,7 +63,7 @@ See `[project.optional-dependencies]` in `pyproject.toml` for the full extras ma

## Workflow

Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. The implementing PR sets `status: shipped` and fills `pr` / `outcome` in the branch, alongside the code and promotes its conclusions into the affected `architecture/<capability>.md` — that hand-edit keeps `architecture/` true and is the only ship-time step; there is no folder move. The change listing is generated — run `just index`. See [`planning/README.md`](planning/README.md) for the conventions and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.
Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. The implementing PR sets `status: shipped` and fills `pr` / `outcome` in the branch, alongside the code and promotes its conclusions into the affected `architecture/<capability>.md` — that hand-edit keeps `architecture/` true and is the only ship-time step; there is no folder move. The change listing is generated — run `just index`. A design decision taken without a code change — especially a candidate rejected with a load-bearing reason — is recorded as `planning/decisions/YYYY-MM-DD-<slug>.md` (the `decision.md` template, frontmatter `status: accepted|superseded`), each with a **Revisit trigger** so future reviews don't re-litigate it; listed by `just index`. See [`planning/README.md`](planning/README.md) for the conventions and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.

**Spec** (`design.md`) captures the *thinking* — why, what the design is, trade-offs, scope. Written before code; rarely revised after merge. **Plan** (`plan.md`) captures the *sequencing* — the ordered checklist an executor walks; references the spec for the "why". **`architecture/`** captures the *invariants* of shipped systems — the living truth, promoted in the implementing PR alongside the code. A plan paragraph that would still read correctly with all task numbers and checkboxes removed is design content and belongs in the spec.

Expand Down
18 changes: 13 additions & 5 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,19 @@ leaves no stale references.

## Cross-instrument integrations

**Logging ↔ Sentry.** `logging_instrument.py` injects structlog context into
Sentry events. `sentry_instrument.py` chains the user's `before_send` after the
built-in structlog enricher via `wrap_before_send_callbacks()`. A `skip_sentry`
flag in the log context suppresses the event; the flag is also stripped from the
Sentry payload (it is in `IGNORED_STRUCTLOG_ATTRIBUTES`).
**Logging ↔ Sentry.** `logging_instrument.py` renders every structlog line to a
flat JSON object via the shared serializer in `logging_factory.py`. The seam
between the two instruments is `StructuredLogPayload` (also in
`logging_factory.py`): its `parse` classmethod reconstructs that line into
`message` / `extra` / `skip_sentry`, owning the meta-key vocabulary
(`STRUCTLOG_META_KEYS`) and stripping it from `extra` — so neither the parsing
detail nor the key set lives in `sentry_instrument.py`. The Sentry side's
`enrich_sentry_event_from_structlog_log` (chained after the user's `before_send`
via `wrap_before_send_callbacks()`) only maps the parsed payload onto the Sentry
event: a truthy `skip_sentry` suppresses the event (checked before the
message-presence test), otherwise it lifts `message` and attaches `extra` under
`contexts.structlog`. `IGNORED_STRUCTLOG_ATTRIBUTES` remains in
`sentry_instrument.py` as a back-compat alias of `STRUCTLOG_META_KEYS`.

**OTel ↔ Logging.** The logging instrument injects span/trace IDs from the
active OpenTelemetry context into every log record, so logs and traces correlate.
Expand Down
45 changes: 45 additions & 0 deletions lite_bootstrap/instruments/logging_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@ def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any
return orjson.dumps(value, **kwargs).decode()


# Meta-keys the producer's structlog processor chain emits at the top level of every
# rendered line (see lite_bootstrap/instruments/logging_instrument.py). They are not
# user-supplied extra and are stripped from StructuredLogPayload.extra. If you add a
# custom top-level meta-processor to that chain, add its key here.
STRUCTLOG_META_KEYS: typing.Final = frozenset(
{"event", "level", "logger", "tracing", "timestamp", "exception", "skip_sentry"}
)


@dataclasses.dataclass(frozen=True, slots=True)
class StructuredLogPayload:
"""The semantic content of one rendered structlog line.

A consumer-side interpretation type: ``parse`` turns the raw JSON string a
structlog processor chain emits into its parts. It holds no knowledge of any
downstream event shape (e.g. Sentry's); mapping onto a consumer's event is the
consumer's job.
"""

message: str | None
extra: dict[str, typing.Any]
skip_sentry: bool

@classmethod
def parse(cls, formatted: str) -> "StructuredLogPayload | None":
"""Interpret one rendered structlog line.

Returns ``None`` when ``formatted`` is not a structlog JSON object (not a
JSON object string, a decode error, or a non-dict result).
"""
if not formatted.startswith("{"):
return None
try:
loaded = orjson.loads(formatted)
except orjson.JSONDecodeError:
return None
if not isinstance(loaded, dict): # pragma: no cover - JSON starting with "{" is always an object
return None

skip_sentry = bool(loaded.get("skip_sentry"))
message = loaded.get("event")
extra = {key: value for key, value in loaded.items() if key not in STRUCTLOG_META_KEYS}
return cls(message=message, extra=extra, skip_sentry=skip_sentry)


if import_checker.is_structlog_installed:
import structlog

Expand Down
4 changes: 4 additions & 0 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
if import_checker.is_opentelemetry_installed:
from opentelemetry import trace

# `tracer_injection` adds the top-level `tracing` meta-key. Any new top-level
# meta-processor added to the chain must register its key in
# `STRUCTLOG_META_KEYS` (logging_factory.py) or it will leak into
# StructuredLogPayload.extra and downstream Sentry context.
def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict":
current_span = trace.get_current_span()
if not current_span.is_recording():
Expand Down
47 changes: 18 additions & 29 deletions lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import dataclasses
import typing

import orjson

from lite_bootstrap import import_checker
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
from lite_bootstrap.instruments.logging_factory import STRUCTLOG_META_KEYS, StructuredLogPayload


if typing.TYPE_CHECKING:
Expand All @@ -16,9 +15,9 @@
import sentry_sdk


IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset(
{"event", "level", "logger", "tracing", "timestamp", "exception", "skip_sentry"}
)
# Back-compat alias: this vocabulary moved to logging_factory and was renamed
# STRUCTLOG_META_KEYS. Preserved here for external importers of the old name.
IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = STRUCTLOG_META_KEYS


@dataclasses.dataclass(kw_only=True, frozen=True)
Expand All @@ -39,35 +38,25 @@ class SentryConfig(BaseConfig):
def enrich_sentry_event_from_structlog_log(
event: "sentry_types.Event", _: "sentry_types.Hint"
) -> typing.Optional["sentry_types.Event"]:
if (
if not (
(logentry := event.get("logentry"))
and (formatted_message := logentry.get("formatted"))
and (isinstance(formatted_message, str))
and formatted_message.startswith("{")
and (isinstance(event.get("contexts"), dict))
and isinstance(formatted_message, str)
and isinstance(event.get("contexts"), dict)
):
try:
loaded_formatted_log = orjson.loads(formatted_message)
except orjson.JSONDecodeError:
return event

if not isinstance(loaded_formatted_log, dict): # pragma: no cover
return event

if loaded_formatted_log.get("skip_sentry"):
return None

if event_name := loaded_formatted_log.get("event"):
event["logentry"]["formatted"] = event_name # ty: ignore[invalid-assignment]
else:
return event
return event

additional_extra = loaded_formatted_log
for one_attr in IGNORED_STRUCTLOG_ATTRIBUTES:
additional_extra.pop(one_attr, None)
if additional_extra:
event["contexts"]["structlog"] = additional_extra
payload = StructuredLogPayload.parse(formatted_message)
if payload is None:
return event
if payload.skip_sentry:
return None
if not payload.message:
return event

event["logentry"]["formatted"] = payload.message # ty: ignore[invalid-assignment]
if payload.extra:
event["contexts"]["structlog"] = payload.extra
return event


Expand Down
18 changes: 13 additions & 5 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ into `design.md` + `plan.md`.
- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope).
- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist).
- **`change.md`** — both, condensed, for the lightweight lane.
- **`decisions/<YYYY-MM-DD>-<slug>.md`** — one file per design decision taken
(especially options *rejected*), each with a revisit trigger, so reviews don't
re-litigate them; listed by `just index`.
- **`releases/<semver>.md`** — per-release user-facing notes.
- **`audits/<date>-<slug>.md`** — findings from a code/docs/bug-hunt sweep;
spawns fix changes.
Expand All @@ -65,21 +68,26 @@ Templates live in [`_templates/`](_templates/).

`design.md` / `change.md`: `status` (draft|approved|shipped|superseded),
`date`, `slug`, `summary` (single line), `supersedes`, `superseded_by`, `pr`,
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. Files in
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`.
`decisions/*.md`: `status` (accepted|superseded), `date`, `slug`, `summary`,
`supersedes`, `superseded_by`, `pr`. Files in
`architecture/` carry **no** frontmatter — living prose, dated by git.

## Index

The change listing is **generated**, not maintained — run `just index` to
print it (grouped by `status`: In progress / Shipped / Superseded). The
frontmatter in each bundle is the single source of truth; there is no
committed copy to drift.
The listing is **generated**, not maintained — run `just index` to print it:
changes grouped by `status` (In progress / Shipped / Superseded), then
decisions newest-first. The frontmatter in each bundle / decision file is the
single source of truth; there is no committed copy to drift.

## Other

- **[`architecture/`](../architecture/)** at the repo root — the living
capability truth (config model, instruments, bootstrappers). The promotion
target on every ship.
- **[decisions/](decisions/)** — design decisions taken (and alternatives
rejected), each with a revisit trigger, so reviews don't re-litigate them;
indexed by `just index`.
- **[audits/](audits/)** — findings reports (2026-05-31 bug+refactor audit,
2026-06-05 bug audit v2).
- **[retros/](retros/)** — what we learned after a body of work.
Expand Down
26 changes: 26 additions & 0 deletions planning/_templates/decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
status: accepted # accepted | superseded
date: YYYY-MM-DD
slug: my-decision
summary: One line — shown in `just index`.
supersedes: null
superseded_by: null
pr: null # PR/commit where the decision was made or recorded
---

# One-line capitalized title

**Decision:** What was decided, in a sentence.

## Context

Why this came up; the options that were on the table.

## Decision & rationale

The call and why — including why the alternatives were rejected. Enough that a
future explorer doesn't re-litigate it.

## Revisit trigger

The concrete signal that should reopen this decision.
Loading