Files
PaperClipAI/server/src/services/duplex-observability-recorder.ts
T
Nicky LeachandPaperclip d1573244b5 refactor: disambiguate the Telemetry and Observability data paths (#12128)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip records first-party events, OpenTelemetry data, and local
run-log events
> - The code and documents used one term for these three data paths
> - This naming made the required review level unclear
> - This pull request names each data path in the module names,
documents, and code comments
> - The benefit is a clear review rule without a runtime change

## Linked Issues or Issue Description

**Issue type**

Unclear or confusing.

**Where is the issue?**

`packages/shared/src/telemetry/README.md`, `doc/observability.md`,
`doc/run-log-events.md`, and the duplex instrumentation modules.

**What's wrong?**

The repository used Telemetry for first-party events, OpenTelemetry
data, and local run-log events. This usage made the data path and review
level unclear.

**Suggested fix**

Use Telemetry only for Paperclip first-party events. Use Observability
for OpenTelemetry data. Use the run log for rows in
`heartbeat_run_events`.

Related public pull requests: #8476 and #9672.

## What Changed

- Rename the duplex instrumentation modules and identifiers from
`Telemetry` to `Observability`.
- Move the Observability and run-log contracts out of the Telemetry
README.
- Add `doc/observability.md` and `doc/run-log-events.md` as the
canonical documents.
- Add a file-path review rule to `AGENTS.md`.
- Correct the remaining code comments that name the wrong data path.
- Keep all event names, payloads, database records, spans, configuration
keys, environment variables, and runtime paths unchanged.

## Verification

- `npx vitest run packages/shared/src/telemetry/readme-contract.test.ts`
passes.
- `npx vitest run packages/adapter-utils/src/published-exports.test.ts`
passes.
- `npx vitest run
packages/adapter-utils/src/acpx-engine/startup-timing.test.ts` passes
with 42 tests.
- `pnpm --filter @paperclipai/adapter-utils typecheck` passes.
- `pnpm --filter server typecheck` passes.
- The old module name does not remain in TypeScript or JSON files,
except for the intentional publication guard.
- CI and Greptile checks remain pending after PR creation.

## Risks

- The old duplex module subpath no longer has a compatibility shim. The
board accepted this intentional hard break.
- The new duplex module subpath stays blocked from package publication.
- The change has no runtime effect. The main risk is an incorrect
document or module reference.

## Model Used

OpenAI GPT-5 Codex, exact model ID `gpt-5`, with tool use and code
review support.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR with the documentation issue
fields
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-24 16:42:33 -07:00

114 lines
4.7 KiB
TypeScript

import {
DUPLEX_DIMENSION_KEYS,
DUPLEX_SPAN_REQUEST,
type DuplexObservabilityCounterRecord,
type DuplexObservabilityDimensions,
type DuplexObservabilityEventRecord,
type DuplexObservabilityRecorder,
type DuplexObservabilitySpanRecord,
} from "@paperclipai/adapter-utils/duplex-observability";
/**
* The host binding for the fixed duplex telemetry surface. This module maps each
* recorder call to a real host sink: the span to the OTel tracer, the counter to
* the guarded counter store in `tool-runtime-metrics.ts`, and the transport event
* to the run-event path. The mapping is the single boundary, so the closed
* dimension keys and the counter folding stay in one place.
*
* The recorder never throws. The facade in `duplex-observability.ts` wraps each
* synchronous call in a swallow, but an async sink can still reject after the
* call returns. Each async sink here runs fire-and-forget with its own catch, so
* a sink failure never breaks the request path.
*/
/** The minimal span the recorder opens. A real OTel span satisfies it; the
* no-op tracer's span satisfies it too. */
export interface DuplexObservabilitySpan {
setAttribute(key: string, value: string | number | boolean): void;
end(endTime?: number): void;
}
/** The minimal tracer surface the recorder calls. `getStartupTracer` returns a
* real or a no-op implementation that satisfies it. The optional second argument
* carries an explicit start time, so the request span duration equals the
* measured latency. */
export interface DuplexObservabilityTracer {
startSpan(name: string, options?: { startTime?: number }): DuplexObservabilitySpan;
}
export interface HostDuplexObservabilityRecorderInput {
/** The OTel tracer for the two duplex spans. */
tracer: DuplexObservabilityTracer;
/**
* Increment one guarded host counter by its metric name. The caller binds it
* to `incrementToolRuntimeMetricCounter` with the company id, inside a swallow.
*/
incrementCounter(metric: string): void;
/**
* Emit one transport event to the run-event path. The caller binds it to the
* run-events bridge, inside a swallow.
*/
emitTransportEvent(event: { name: string; dimensions: DuplexObservabilityDimensions }): void;
/** The wall clock. The default is `Date.now`. Tests inject a fixed clock. */
now?: () => number;
}
/**
* Fold the discriminating dimension into the counter metric name. The guarded
* counter store keys on `(companyId, metric)` with no label column, so the
* fallback reason or the loss class rides the metric name to keep the analytic
* breakdown. Both dimension sets are closed and low-cardinality, so the metric
* cardinality stays bounded. A record with neither dimension uses the base name.
*/
export function foldDuplexCounterMetric(record: DuplexObservabilityCounterRecord): string {
if (record.dimensions.fallback_reason) {
return `${record.metric}.${record.dimensions.fallback_reason}`;
}
if (record.dimensions.loss_class) {
return `${record.metric}.${record.dimensions.loss_class}`;
}
return record.metric;
}
/**
* Build the host duplex telemetry recorder. The recorder receives only
* already-mapped dimensions, so a raw provider key never reaches a sink. Every
* span attribute uses only the closed dimension keys.
*/
export function createHostDuplexObservabilityRecorder(
input: HostDuplexObservabilityRecorderInput,
): DuplexObservabilityRecorder {
const now = input.now ?? Date.now;
const setDimensionAttributes = (span: DuplexObservabilitySpan, dimensions: DuplexObservabilityDimensions): void => {
for (const key of DUPLEX_DIMENSION_KEYS) {
const value = dimensions[key];
if (typeof value === "string") {
span.setAttribute(key, value);
}
}
};
return {
recordSpan(record: DuplexObservabilitySpanRecord): void {
// The request span carries a latency, so start it in the past and end it
// now, so the span duration equals the measured latency. The channel-open
// span carries no latency, so it opens and ends at the same instant.
const end = now();
const latencyMs =
record.name === DUPLEX_SPAN_REQUEST && typeof record.latencyMs === "number" && Number.isFinite(record.latencyMs)
? Math.max(0, record.latencyMs)
: 0;
const span = input.tracer.startSpan(record.name, { startTime: end - latencyMs });
setDimensionAttributes(span, record.dimensions);
span.end(end);
},
incrementCounter(record: DuplexObservabilityCounterRecord): void {
input.incrementCounter(foldDuplexCounterMetric(record));
},
emitEvent(record: DuplexObservabilityEventRecord): void {
input.emitTransportEvent({ name: record.name, dimensions: record.dimensions });
},
};
}