Observability
Provider-independent interfaces
The core has zero mandatory dependency on any logging/metrics vendor
(spec section 59), mirroring the same "AI independence" principle
applied to embeddings (spec section 19). Two small interfaces in
@memorie/types:
export interface Logger {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
export interface MetricsProvider {
increment(name: string, value?: number, tags?: Record<string, string>): void;
gauge(name: string, value: number, tags?: Record<string, string>): void;
histogram(name: string, value: number, tags?: Record<string, string>): void;
}Wire in whatever you already use — pino/winston/console for Logger;
StatsD/Prometheus/OpenTelemetry metrics/a test double for
MetricsProvider:
const memory = createMemoryEngine({
memoryStore,
observability: { logger: myPinoAdapter, metrics: myStatsdAdapter },
});Both are independently optional. Without them configured, every logging/ metrics call inside the engine is a no-op — same behavior as before this was added.
What's tracked
| Metric | Emitted by |
|---|---|
memorie.memory.created (counter) | add() |
memorie.memory.updated (counter) | update() |
memorie.memory.deleted (counter) | delete() |
memorie.get.latency_ms (histogram) | get() |
memorie.search.latency_ms (histogram) | search() |
memorie.cache.hit / memorie.cache.miss (counters) | get(), when a CacheStore is configured |
memorie.conflict.detected (counter) | ingest(), when a conflict is raised |
memorie.conflict.resolved (counter, tagged by strategy) | resolveConflict(), non-merge strategies |
memorie.consolidation (counter) | resolveConflict() with "merge", and consolidate() |
This is the metric set named in spec section 59 minus "retrieval
latency" duplicated as both a generic name and get()'s specific one —
memorie.get.latency_ms is retrieval latency.
What's logged
Debug-level structured logs on add()/update()/delete(), info-level
on reindex()/reconcile()/export()/import() completion. Never
memory content — only ids, namespace/subjectId/type, versions,
and counts (spec section 43/59: "Never log full memory contents by
default"). See docs/SECURITY for the reasoning and
packages/core/test/observability-security.test.ts for a test that
asserts a log entry's context never contains a value that was only in
content.
Tracing
The third signal from spec section 59 ("provider-independent interfaces
for logging, metrics, tracing"). Same optional/no-op pattern as Logger
and MetricsProvider: two small interfaces in @memorie/types,
export interface Span {
setAttribute(key: string, value: string | number | boolean): void;
recordException(error: unknown): void;
end(): void;
}
export interface Tracer {
startSpan(
name: string,
options?: { attributes?: Record<string, string | number | boolean>; parent?: Span },
): Span;
}wired in the same way as the other two:
const memory = createMemoryEngine({
memoryStore,
observability: { logger: myPinoAdapter, metrics: myStatsdAdapter, tracer: myOtelAdapter },
});Without a tracer configured, startSpan() calls inside the engine
return a shared no-op Span — every method on it does nothing — so
there's no branch at each call site and no cost when tracing isn't
wired in.
options.parent lets a caller nest an engine operation's span under a
span it already started itself, for tracers that support parent/child
spans (e.g. OpenTelemetry). Implementations that don't support nesting
can ignore it and start a new root span.
What's traced
One span per call, named memorie.<operation>, covering the same
operations already covered by metrics/logs above: add, get,
update, delete, search, ingest, resolveConflict,
consolidate. Each span:
- gets scope attributes up front where known (e.g.
namespace,type, or the memory/conflict id being operated on); - gets result attributes set once the operation succeeds (e.g.
memory.idonadd(),result.countonsearch(),cache.hitonget()); - has
recordException()called with the thrown error if the operation fails, before the error is rethrown; - is always
end()-ed, success or failure, viatry/finally— the same shape as theget()/search()latency histograms above.
Other read/maintenance methods (list, count, history, related,
reindex, reconcile, export, import, ...) are not yet
individually spanned — a caller wrapping its own span around a call to
one of these captures it just fine via options.parent, but the engine
itself doesn't emit a nested child span for it. Extending coverage to
those follows the exact same shape as the operations above.
See packages/core/test/observability-tracing.test.ts for the
no-op-without-a-tracer guarantee and the span/attribute/exception
behavior above.