@crow-agent/telemetry
v0.2.0
Published
Vendor-neutral telemetry contracts with a reference adapter, conformance suite, and typed schemas.
Readme
@crow-agent/telemetry
Telemetry without a vendor. This package defines the contracts Crow packages use to record what they did — spans, attributes, events, statuses — plus typed schema helpers for keeping that vocabulary consistent. There is no exporter here, no ambient "current span", and no backend dependency: you bring the backend (OpenTelemetry, Sentry, logs, …) by implementing the adapter contract, or you use the bundled in-memory reference for tests and local diagnostics.
The core idea
A span is a timed record of one operation: it opens before the work and
closes when the work finishes. Spans nest — a request span can contain a cache
lookup and a database query — forming a tree that shows where time went. Each
span carries attributes (named facts like account.id=123), events
(point-in-time occurrences like cache.lookup, optionally with their own
attributes), and a status (ok, or error with a name and message).
A TelemetryContext is the handle that says where new work belongs in that
tree. Starting a span from a context makes the new span its child. The API is
callback-based and explicit: contexts are passed as arguments, never stashed in
ambient state.
import {
NOOP_TELEMETRY_CONTEXT,
type TelemetryContext,
} from "@crow-agent/telemetry";
async function fetchProfile(
profileId: string,
telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT,
) {
return telemetryContext.startSpan(
{
name: "example.profile.fetch",
attributes: { "example.profile.id": profileId },
},
async (span) => {
const profile = await readProfile(profileId);
span.setAttributes({ "example.profile.found": profile !== undefined });
return profile;
},
);
}Hand the callback's span deeper to nest explicitly — a span doubles as the child context for its own children:
return telemetryContext.startSpan({ name: "example.parent" }, async (parent) => {
return parent.startSpan({ name: "example.child" }, async (child) => {
child.addEvent("example.cache.lookup", { "example.cache.hit": true });
return performWork();
});
});There is no public end(). startSpan() owns settlement: the span stays open
until the callback's value or promise settles. When a failure is an expected
outcome rather than a throw, set the status yourself:
return telemetryContext.startSpan({ name: "example.save" }, async (span) => {
const result = await save();
if (!result.ok) {
span.setStatus({
status: "error",
error: { name: "SaveError", message: result.reason },
});
}
return result;
});Recording must never change the work: a span is diagnostic data, not business state. Whether the account load runs, succeeds, fails, or persists must be identical with telemetry on or off.
Writing an adapter
An adapter implements TelemetryContext and translates the generic API into a
backend's native concepts. The contract is behavioral — these are the
observable semantics your adapter must honor:
- Invoke the callback synchronously, exactly once.
- Preserve the callback's value and rejection identity: a synchronous throw becomes a promise rejected with the same value.
- Keep the native span open until a returned promise settles.
- Default normal completion to
okand throws/rejections toerror, unless an explicit status was set. setStatus()is last-write-wins.setAttributes()merges: later defined values replace earlier ones,undefinedis ignored.- Recording methods are synchronous, passive, and never throw.
- Calls after settlement are inert.
- A failed recording call is ignored atomically — backend failures are suppressed — while the business callback still runs exactly once.
Adapters may use backend-native ambient context internally for automatic instrumentation, but Crow code always passes the parent explicitly. Exporter buffering, flushing, sampling, backend IDs, and backend-specific context objects are the adapter's business, not the contract's. The conformance suite checks all of this for you.
The no-op context
When telemetry is optional, default to NOOP_TELEMETRY_CONTEXT:
import { NOOP_TELEMETRY_CONTEXT } from "@crow-agent/telemetry";
const noopResult = await NOOP_TELEMETRY_CONTEXT.startSpan(
{ name: "example.operation" },
() => runOperation(),
);It invokes callbacks synchronously, preserves values and rejections (turning sync throws into rejections with the same value), and reuses one shared frozen inert span for every nesting level. It never inspects or retains names, attributes, events, or statuses.
The in-memory reference
InMemoryTelemetryContext is the backend-neutral reference implementation —
handy for tests, local diagnostics, or any app that deliberately wants
process-local capture with no exporter:
import { InMemoryTelemetryContext } from "@crow-agent/telemetry";
const recorder = new InMemoryTelemetryContext();
await recorder.startSpan(
{ name: "example.operation", attributes: { input: "demo" } },
async (span) => {
span.addEvent("example.started");
span.setAttributes({ "example.results": 3 });
},
);
console.log(recorder.getSpans());getSpans() returns detached snapshots in span-start order. Each
RecordedTelemetrySpan has a deterministic numeric ID, parent ID, merged
attributes, ordered events, final status, and settlement state — but no
timestamps, by design, so snapshots compare cleanly.
Storage is unbounded and process-local: create a fresh instance per test or recording scope, and don't record sensitive attributes unless the caller's data policy allows it.
Testing adapters
@crow-agent/telemetry/testing ships a runner-independent conformance suite. You
supply a fixture — a fresh context plus a function that converts your backend's
finished spans into normalized RecordedTelemetrySpan snapshots — and the suite
supplies grouped cases:
import {
createTelemetryAdapterConformance,
type TelemetryAdapterFixture,
} from "@crow-agent/telemetry/testing";
import { describe, it } from "vitest";
const cases = createTelemetryAdapterConformance(async () => {
const backend = createMyTelemetryAdapter();
return {
context: backend.context,
getSpans: async () => backend.normalizedSpans(),
async [Symbol.asyncDispose]() {
await backend.close();
},
} satisfies TelemetryAdapterFixture;
});
for (const group of new Set(cases.map((c) => c.group))) {
describe(group, () => {
for (const testCase of cases.filter((c) => c.group === group)) {
it(testCase.name, () => testCase.run());
}
});
}Cases cover synchronous single admission, value and rejection identity,
automatic vs. explicit status, attribute merging, event ordering, inert
post-settlement calls, nested and concurrent parentage, and suppression of
unreadable telemetry payloads. getSpans() may flush an async exporter before
returning. The testing subpath uses Node's assertion APIs; the root package
stays runtime-neutral.
Typed schemas
The span API deliberately accepts open names and attribute bags so adapters stay generic. Domain packages layer closed, serializable schemas on top and infer exact TypeScript types from them:
import {
createTypedSpanStarter,
defineTelemetrySchema,
} from "@crow-agent/telemetry";
export const EXAMPLE_TELEMETRY_SCHEMA =
defineTelemetrySchema({
version: 1,
spans: {
"example.read": {
description: "Read one resource",
parents: { kind: "any" },
startAttributes: {
"example.resource": {
type: "string",
required: true,
values: ["account", "project"],
description: "Resource kind",
},
},
endAttributes: {
"example.item_count": {
type: "number",
description: "Number of returned items",
},
},
events: {
"example.cache": {
description: "Cache lookup result",
attributes: {
"example.cache.hit": {
type: "boolean",
required: true,
description: "Whether the cache contained the resource",
},
},
},
},
status: {
default: "ok",
errorWhen: "The read throws or returns an error result",
},
},
},
} as const);
const startExampleSpan = createTypedSpanStarter(
telemetryContext,
[EXAMPLE_TELEMETRY_SCHEMA],
);The starter exposes one overload per span and checks names and attributes at compile time. Its callback receives a child starter over the same schemas, already bound to the callback's span:
await startExampleSpan(
"example.read",
{ "example.resource": "account" },
async (span, startChildSpan) => {
span.addEvent("example.cache", { "example.cache.hit": true });
const accounts = await readAccounts();
span.setAttributes({ "example.item_count": accounts.length });
await startChildSpan(
"example.read",
{ "example.resource": "project" },
async (childSpan) => {
const projects = await readProjects();
childSpan.setAttributes({ "example.item_count": projects.length });
},
);
return accounts;
},
);defineTelemetrySchema() is a typed identity function: it returns ordinary
JSON-serializable data and performs no runtime validation or parent-rule
enforcement. Schemas are not merged, inspected, or retained at runtime, and
duplicate literal span names across the array are a compile error.
Start attributes vs. end attributes
startAttributes and endAttributes describe when an attribute is normally
known — not separate storage. Both become ordinary attributes on the same
backend span:
| Declared in | Passed via | Requiredness |
|---|---|---|
| startAttributes | The starter's attributes argument, when the span is created | Each definition sets required: true or false |
| endAttributes | The schema-scoped span's setAttributes(), any time before settlement | Always optional |
So example.resource is known when example.read starts, while
example.item_count is only known after readAccounts() returns. "End" here
means completion enrichment: an end attribute may be set at any point while the
callback is active and omitted when unavailable — calling setAttributes() zero
times is valid, which matters for early failures, cancellation, and
provider-specific data that may not exist on every path.
Repeated setAttributes() calls merge into the same bag: a later defined value
replaces an earlier one for the same key, undefined is ignored, and the
schema-scoped method only accepts the current span's declared end attributes.
Attributes never end the span — returning, resolving, throwing, or rejecting
from the callback does; startSpan() performs the actual end operation, and
adapter calls after settlement are inert.
A starter composes multiple independently versioned schemas:
import { AGENT_TELEMETRY_SCHEMAS } from "@crow-agent/agent-core";
const startCrowAgentSpan = createTypedSpanStarter(
telemetryContext,
AGENT_TELEMETRY_SCHEMAS,
);Inline schema arrays keep their tuple types automatically; separately declared
arrays need as const. Union-valued names must be narrowed before a call so the
runtime name stays tied to its attribute schema. The schema-derived types reject
missing required attributes, unknown keys, invalid closed-set values, undeclared
events, and attributes on empty schemas.
Schema metadata
Supported attribute types: string, number, boolean, and their array forms
string[], number[], boolean[]. Attribute definitions support:
values— closed set for scalar values;elementValues— closed set for array elements;examples— documentation examples;sensitive— marks data needing special handling;cardinality— expectedloworhighcardinality.
required is declared on start and event attributes; end attributes are always
optional (see above). Parent metadata is descriptive schema data:
{ kind: "any" }— root or any caller span;{ kind: "root_or_external" }— root or a caller-owned span outside the schema;{ kind: "spans", spans: [...] }— only the listed schema spans.
Adapters never need to understand schema objects; instrumentation helpers and tests use them to keep emitted names and attributes consistent.
How Crow packages use it
Ownership is split on purpose:
@crow-agent/telemetryowns the vendor-neutral contract, the no-op and in-memory contexts, the schema utilities, and the conformance suite.@crow-agent/aiaccepts and propagatestelemetryContextin provider request options but owns no telemetry schema.@crow-agent/agent-coreowns the AI-request and harness schemas, exports them as a combined readonly tuple, and provides typed span helpers:
import {
AGENT_TELEMETRY_SCHEMAS,
AI_TELEMETRY_SCHEMA,
HARNESS_TELEMETRY_SCHEMA,
startAiSpan,
startHarnessSpan,
} from "@crow-agent/agent-core";The Crow schemas speak a Crow-owned vocabulary — crow.ai.*, crow.harness.*,
crow.session.*. Adapters may translate those names into backend conventions
without changing what Crow emits.
Security and portability
Telemetry is process-local diagnostics, not durable application state. Never
persist a TelemetryContext, a TelemetrySpan, or a backend-native trace
object in records, messages, snapshots, or deferred handles.
Attribute values are intentionally limited to primitive scalars and arrays. Keep prompts, completions, tool arguments and output, file contents, provider payloads, headers, credentials, and free-form error details out of attributes unless a schema and data policy explicitly allow them.
The package avoids AsyncLocalStorage and every other runtime-specific ambient
context API, so it runs on Node.js, Bun, browsers, and workers alike. Backend
adapters own their own runtime compatibility.
API reference
Core types and values
| Export | Purpose |
|---|---|
| TelemetryContext | Opens callback-scoped child spans |
| TelemetrySpan | Captures attributes, events, and status; doubles as a child context |
| SpanOptions | Span name with optional starting attributes |
| SpanAttributes / AttributeValue | Open attribute bag and its value types at the adapter level |
| SpanStatus | Explicit ok / error outcome |
| NOOP_TELEMETRY_CONTEXT | Shared inert context for when telemetry is off |
| InMemoryTelemetryContext | Deterministic process-local reference adapter |
| RecordedTelemetrySpan | Snapshot of one captured span |
| RecordedTelemetryEvent | Snapshot of one captured event |
Schema definitions and inference
| Export | Purpose |
|---|---|
| defineTelemetrySchema() | Typed identity for serializable schema data |
| createTypedSpanStarter() | Binds a parent context to one or more schema vocabularies |
| TypedSpanStarter | Exact starter type; callbacks receive recursively child-bound starters |
| TelemetrySchemaDefinition | Top-level schema shape |
| TelemetrySpanDefinition | One span's metadata, parents, attributes, events, and status rule |
| TelemetryAttributeType | Scalar and array type names |
| TelemetryAttributeMetadata | Description, sensitivity, and cardinality notes |
| TelemetryAttributeDefinition | Attribute type, allowed values, examples, metadata |
| TelemetryEventAttributeDefinition | Event attribute definition, with requiredness |
| TelemetryEventDefinition | Event description with its attribute definitions |
| TelemetryParentDefinition | Open, external-root, or finite parent rule |
| TelemetrySchemaSpanName | Union of the span names a schema declares |
| TelemetrySchemaSpanStartAttributes | Exact inferred start attributes for a span |
| TelemetrySchemaSpanEndAttributes | Optional inferred end attributes for a span |
| TelemetrySchemaSpanEventName | Union of the events a span declares |
| TelemetrySchemaSpanEventAttributes | Exact inferred attributes for an event |
| SchemaTelemetrySpan | Span view narrowed to one schema span |
| TelemetrySchemaSpanUnion | Discriminated union of every span in a schema |
| InferStartAttributes | Required and optional values inferred from start definitions |
| InferOptionalAttributes | Optional values inferred from end definitions |
| InferEventAttributes | Required and optional values inferred from event definitions |
| InferRequiredAndOptionalAttributes | Shared inference helper for definitions with requiredness |
| ExactTelemetryAttributes | Rejects attribute keys outside the expected set |
Testing subpath
| Export | Purpose |
|---|---|
| createTelemetryAdapterConformance() | Builds runner-independent conformance cases |
| TelemetryAdapterFixture | Fresh context plus normalized snapshot reader |
| TelemetryAdapterFixtureFactory | Produces isolated fixtures |
| TelemetryAdapterConformanceCase | One grouped case for a test runner to execute |
Development
From this package directory:
npm test
npm run buildLicense
MIT
