Architecture¶
Where each kind of code lives. This is binding: a file in the wrong folder is a bug.
Conventions (adopted from agno)¶
- Feature folders, not layer folders.
events/,run/,writers/. Never a globalmodels/+services/split. - Fixed file roles. Inside a feature:
schemas.py= data shapes,setup.py= the one-call entry point, everything else = one concern per file. __init__.pyonly re-exports, with an explicit__all__. It contains no logic. Leaf modules import siblings by full path, never through a package__init__(circular imports).- Plain dataclasses with explicit
to_dict/from_dict. No pydantic in the data layer. - Optional third-party imports live behind
try/except ImportErrorwith a module-level_AVAILABLEflag and an error message naming the exactpip install. - Cross-cutting helpers are functions in
utils.py, not objects.
Rules (ours)¶
- Data shapes only in a
schemas.py. No dataclass is defined anywhere else. - Constants only in
constants.py. File names, layout widths, defaults, environment variable names, redaction patterns and the OpenTelemetry attribute tables all live there. A literal filename or magic number anywhere else is a bug: the run folder layout is a published contract, and it should be readable in one file rather than reconstructed from twelve. - Utilities only in
utils.py. Logging, redaction and formatting helpers that know nothing about runs or events. Anything that knows about a run belongs to the module that owns that concept. - Disk writes only in
writers/andrun/store.py. Noopen()/Path.write_*elsewhere. - Optional dependencies only in
integrations/. The core never imports opentelemetry. - Never raise into the host app. Every public entry point catches, logs via
utils, and continues. Broken telemetry must not break an agent run. - No ABC until there is a second implementation. There is one storage backend: the filesystem.
- Integrations are idempotent. A tracer provider cannot have a processor
removed, so a second
start()re-points the installed one instead of stacking another. - No truncation in
writers/transcript.py. Oversized payloads spill toblobs/; they are never silently shortened.
Adoption rules¶
Ease of adoption outranks every other consideration here. It is not a breakthrough project; it is only used if it is trivial to start.
- One required call.
aftersight.start(), or none at all viaaftersight run. Everything else (span,trace,log) is optional depth a user reaches for later. - No setup step. The telemetry root,
NAVIGATE.mdand the.gitignoreentry are created on first use. There is noinitto discover or forget. - No optional extras to figure out. OpenTelemetry is a hard dependency so
that
pip install aftersightis the whole install. - Nothing to configure. Every
Configfield has a working default; environment variables andstart()arguments are the only two ways to change one, and neither is ever required. - Say something. One line to stderr on start, naming the folder. Silent instrumentation reads as broken instrumentation.
Tree¶
aftersight/
__init__.py public API re-exports: start, span, trace, log, current, artifact_dir
constants.py every constant: file names, layout, defaults, env vars, OTel dialects
utils.py logging, redaction, size formatting
config.py Config dataclass; defaults, then env, then start() arguments
autostart.py entry point for `aftersight run`
cli.py run | skill
events/
schemas.py Event dataclass
sink.py seq allocation, thread-safe emit, fan-out to writers
run/
api.py contextvars + public start() / span() / trace / log()
run.py Run: dir resolution, session resume, attempt banner, finalize
store.py read side: root creation, index.jsonl, pointers, retention
writers/ registered in this order; BlobWriter normalises the payload
blobs.py blobs/<sha1>.txt spill above config.blob_max_bytes
transcript.py agent.logs framed blocks, #seq anchors, no truncation
trace.py trace.jsonl one event per line; the source of truth
outline.py outline.md folded map, regenerated at finalize
analytics.py analytics.json status, timings, per-agent cost, errors
integrations/
otel.py SpanProcessor + setup_tracing(); reads the dialect tables
stdlog.py logging.Handler bridge (WARNING+ by default)
assets/
NAVIGATE.md written into the telemetry root on first use
skill/SKILL.md Claude Code skill installed by `aftersight skill`
examples/generate.py regenerates the fixture below from the real writers
examples/telemetry_root/ worked fixture, the format spec, see below
tests/test_formats.py asserts the invariants the NAVIGATE.md recipes rely on
Run directory¶
Pointers live outside runs/ so that runs/*/... globs cannot double-count a
run through latest or sessions/.
<root>/ default .runs/, gitignored
NAVIGATE.md the agent's map + rg/jq recipes
index.jsonl one line per run: status, cost, duration, error, tags
latest -> runs/<run_id>
sessions/<session_id> -> ../runs/<run_id>
runs/<YYYYMMDD_HHMMSS_hash>/
outline.md folded map, regenerated at finalize, read first
agent.logs full transcript, append-only across attempts
trace.jsonl machine view, carries parent_seq
analytics.json status, timings, per-agent cost, error list
meta.json framework, git sha, argv, tags, attempts[]
blobs/<sha1>.txt payloads above config.blob_max_bytes
artifacts/ host app drops whatever it wants
A worked example of every one of these files lives in examples/telemetry_root/
at the repository root.
It is the format spec: the writers are built to reproduce it, and the recipes in
its NAVIGATE.md are verified against it.
Format invariants¶
#seqis zero-padded to 4 and is the same number inoutline.md,agent.logsandtrace.jsonl. It never resets, never repeats, and survives a resume.- Column 3 of
agent.logsis the delta from the previous event, not a duration. - A framed block repeats its anchor on the closing line, so a hit landing inside it can find both edges.
status: "error"appears onagent.endtoo. Anything counting failures must filter onpayload.error_type.dur_msis present onrun.end(the run total). Anything ranking steps must excluderun.*.