symfonic.memory.telemetry.episodic_sink¶
episodic_sink ¶
Episodic-write telemetry sink.
Captures a structured record for every successful episodic write so the future LLM-based Phase 12 pattern extractor (deferred to v6.2) has a real corpus to evaluate against.
Design constraints (see docs/releases/v6.1-plan.md T01):
- Zero cost when disabled. The default
Nonesink path does not serialise, does not await, does not allocate. - Serialisation happens off the caller's critical path. When the sink is enabled we still build the record synchronously (it is a handful of primitive fields), but the actual write (logger call, file append) is quick and idempotent.
- Additive only. Adding fields to :class:
EpisodicTelemetryRecordin v6.2 must not break existing consumers reading JSONL output. - Purely best-effort. A failing sink MUST NOT break the episodic write — failures log at DEBUG level and move on.
EpisodicTelemetryRecord
dataclass
¶
EpisodicTelemetryRecord(
timestamp: str,
tenant_id: str,
scope: str,
episode_id: str,
node_count: int,
source_phase: str,
action_type: str | None = None,
extras: dict[str, object] = dict(),
tokens_in: int | None = None,
tokens_out: int | None = None,
input_tokens: int | None = None,
output_tokens: int | None = None,
cost_usd: float | None = None,
model: str | None = None,
embedding_latency_ms: float | None = None,
)
Structured telemetry record for a single successful episodic write.
The field set is the minimum viable shape. v6.2 (T05) added six
optional fields relevant to the LLM-extractor corpus: tokens_in,
tokens_out, input_tokens, output_tokens, cost_usd,
model, and embedding_latency_ms. All are None by default
so the existing JSONL shape is unchanged for records that omit them;
the to_dict / to_jsonl serialisers lift populated values
into the output and drop fields that remain None to keep the
corpus lean.
Do NOT remove or rename fields without a major-version bump — downstream consumers (the eventual LLM extractor corpus) depend on the shape.
to_dict ¶
Return a JSON-serialisable dict representation.
Fields that remain None (i.e. the engine did not source the
signal for this write) are dropped so the JSONL stream keeps
the v6.1 shape for records that carry none of the v6.2 fields.
Source code in src/symfonic/memory/telemetry/episodic_sink.py
to_jsonl ¶
EpisodicTelemetrySink ¶
Bases: Protocol
Protocol implemented by all episodic telemetry sinks.
JsonlEpisodicSink ¶
Append records to a JSON-lines file at the configured path.
- Path is created lazily on first
emit. - Writes are append-only; no in-process buffering (the OS buffer is sufficient for the expected traffic shape — a single write per successful episodic entry).
- File-handle management is per-call: we reopen on each emit. This is intentional: keeps the sink reentrant across event loops and avoids stale handles after log rotation.
Source code in src/symfonic/memory/telemetry/episodic_sink.py
LoggingEpisodicSink ¶
Emit records to the stdlib logging framework at INFO level.
The log record carries the structured dict in extra so a
downstream JSON log handler can lift the fields without parsing the
formatted message. Intended for deployments that already pipe
python logging into a structured log collector (Loki, Datadog).
Level requirement (important)¶
The record is emitted at INFO, but the named logger
"symfonic.memory.telemetry.episodic" inherits its effective
level from the root logger, which defaults to WARNING in
production. Without caller configuration, INFO records are filtered
BEFORE reaching any handler and the sink appears silent -- a
customer setting FrameworkConfig.episodic_telemetry_sink="logger"
and seeing no output is almost always a level misconfiguration,
not a broken sink.
Add this once in your application's logging config::
import logging
logging.getLogger("symfonic.memory.telemetry.episodic").setLevel(
logging.INFO,
)
This sink intentionally does NOT auto-install a handler or raise
the logger level -- that would be too magic for a Protocol sink
shared by every EpisodicLayer.store_event call.
Source code in src/symfonic/memory/telemetry/episodic_sink.py
NullEpisodicSink ¶
No-op sink. Used when telemetry is disabled.
EpisodicLayer branches on sink is None BEFORE constructing the
record, so this class is only used as an explicit marker for tests
and type contracts — the hot path does not instantiate it.
build_record ¶
build_record(
*,
tenant_id: str,
scope: str,
episode_id: str,
source_phase: str,
node_count: int = 1,
action_type: str | None = None,
extras: dict[str, object] | None = None,
tokens_in: int | None = None,
tokens_out: int | None = None,
input_tokens: int | None = None,
output_tokens: int | None = None,
cost_usd: float | None = None,
model: str | None = None,
embedding_latency_ms: float | None = None,
) -> EpisodicTelemetryRecord
Construct a record with an ISO-8601 UTC timestamp.
Factored out of :class:EpisodicLayer so tests can build records
directly without the layer stack.
v6.2 T05 exposes optional token / cost / embedding-latency fields.
See :class:EpisodicTelemetryRecord for the field semantics.
Source code in src/symfonic/memory/telemetry/episodic_sink.py
resolve_episodic_sink ¶
Turn a FrameworkConfig.episodic_telemetry_sink value into a sink.
Mapping:
Noneor empty string ->None(sink disabled, zero cost)."logger"-> :class:LoggingEpisodicSink.- Anything else -> :class:
JsonlEpisodicSinkwith the value treated as a filesystem path.