mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
114 lines
4.7 KiB
TypeScript
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 });
|
|
},
|
|
};
|
|
}
|