operon-agents 0.1.0-alpha.4 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,3 +16,40 @@ import { TransportMCPServer } from "operon-agents/mcp";
16
16
  import { bashTool, readTool } from "operon-agents/tools";
17
17
  import { OTelTracingProcessor } from "operon-agents/tracing";
18
18
  ```
19
+
20
+ ## Tracing
21
+
22
+ Every session drives a `TracingProcessor` registered on the harness scope as `T.Tracing`: one
23
+ trace per run (a prompt and everything it triggers), spans for agents, turns, model generations,
24
+ tool calls and sub-agents, the session id on every span as `gen_ai.conversation.id`. By default
25
+ spans are metadata only. Pass `content: "delta"` to also record the system prompt, the messages
26
+ the model was asked to answer, its output, tool arguments and results — enough to replay a
27
+ conversation in a trace viewer. Local Jaeger:
28
+
29
+ ```sh
30
+ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.9.0
31
+ ```
32
+
33
+ ```ts
34
+ import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
35
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
36
+ import { resourceFromAttributes } from "@opentelemetry/resources";
37
+ import { OTelTracingProcessor } from "operon-agents/tracing";
38
+ import { T } from "operon-agents/core";
39
+
40
+ const provider = new NodeTracerProvider({
41
+ resource: resourceFromAttributes({ "service.name": "my-agent" }),
42
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }))],
43
+ });
44
+ const tracing = new OTelTracingProcessor({ tracer: provider.getTracer("my-agent"), tracerProvider: provider, content: "delta", shutdownProvider: true });
45
+
46
+ const harness = await createLocalHarness({
47
+ model,
48
+ harness: (scope) => scope.register(T.Tracing, tracing),
49
+ });
50
+ ```
51
+
52
+ `content: "full"` repeats the entire context on every generation (trace size grows with steps ×
53
+ context); `contentMaxChars` caps each attribute; `redact: true` masks tokens, emails and keys
54
+ before export when the collector is not on the same machine.
55
+
package/dist/harness.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * low-level "assemble it yourself / stateless server" path, import from
12
12
  * `operon-agents/core` instead.
13
13
  */
14
- import { type AgentEvent, type EventPublicationMode, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type ExternalOriginMetadataValue, type SteerChannel, type SteerOrigin, Agent, Runner, Session, McpOAuthService, type HookDef, type McpServerConfig, type PluginManager, type ApprovalRequestOptions, type Machine, type MachineFactory, type ResolvedAgentProfile, type SubagentProvider, Scope } from "operon-agents-core";
14
+ import { type AgentEvent, type EventPublicationMode, type TelemetryService, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type ExternalOriginMetadataValue, type SteerChannel, type SteerOrigin, Agent, Runner, Session, McpOAuthService, type HookDef, type McpServerConfig, type PluginManager, type ApprovalRequestOptions, type Machine, type MachineFactory, type ResolvedAgentProfile, type SubagentProvider, Scope } from "operon-agents-core";
15
15
  import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ServiceOptions } from "./extensions/index.ts";
16
16
  import { type CommandResult } from "operon-agents-core";
17
17
  export type ApprovalHandler = (request: ApprovalRequest, options?: ApprovalRequestOptions) => Promise<ApprovalResponse> | ApprovalResponse;
@@ -160,6 +160,12 @@ export interface HarnessOptions<TContext = unknown> {
160
160
  /** Event durability policy. Defaults to `immediate` for low-latency local applications.
161
161
  * Managed servers override each opened session to `committed`. */
162
162
  readonly eventPublication?: EventPublicationMode;
163
+ /**
164
+ * Product telemetry (docs/telemetry.md). Process-lifetime: create ONE service, attach the
165
+ * appenders once, pass the same instance to every harness. Absent = nothing is counted.
166
+ * The harness never shuts it down; the product does, before exit.
167
+ */
168
+ readonly telemetry?: TelemetryService;
163
169
  /**
164
170
  * Extra subagent profiles merged into the builtin `coder`/`explore`/`plan` fleet. The SOURCE is
165
171
  * the composition root's concern — disk (`loadAgentProfiles`) locally, external on a server —
package/dist/harness.js CHANGED
@@ -888,6 +888,8 @@ export class Harness {
888
888
  this.scope.provide(T.SessionRepository, () => new MemorySessionRepository());
889
889
  if (options.eventPublication !== undefined)
890
890
  this.scope.register(T.EventPublication, options.eventPublication);
891
+ if (options.telemetry !== undefined)
892
+ this.scope.register(T.Telemetry, options.telemetry, { owned: false });
891
893
  const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
892
894
  this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
893
895
  // The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
@@ -1435,6 +1437,8 @@ export class Harness {
1435
1437
  // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1436
1438
  // race the subscription, and anything capabilities emit during open is already folded.
1437
1439
  let preloadedLog = await readLog(store);
1440
+ // A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
1441
+ const resumed = preloadedLog.length > 0;
1438
1442
  const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1439
1443
  const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1440
1444
  if (!interrupted) {
@@ -1448,7 +1452,7 @@ export class Harness {
1448
1452
  ownMachine: opts.machine !== undefined,
1449
1453
  ...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
1450
1454
  }, params);
1451
- const core = await Session.open(scope, { capabilities, preloadedLog });
1455
+ const core = await Session.open(scope, { capabilities, preloadedLog, resumed });
1452
1456
  const agent = opts.agent ??
1453
1457
  this.options.agent ??
1454
1458
  (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
package/dist/index.d.ts CHANGED
@@ -22,4 +22,5 @@ export * as mcp from "operon-agents-core/mcp";
22
22
  export * as protocol from "operon-agents-core/protocol";
23
23
  export * as tools from "operon-agents-core/tools";
24
24
  export * as tracing from "operon-agents-core/tracing";
25
+ export * as telemetry from "./telemetry.ts";
25
26
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -27,4 +27,5 @@ export * as mcp from "operon-agents-core/mcp";
27
27
  export * as protocol from "operon-agents-core/protocol";
28
28
  export * as tools from "operon-agents-core/tools";
29
29
  export * as tracing from "operon-agents-core/tracing";
30
+ export * as telemetry from "./telemetry.js";
30
31
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
3
+ *
4
+ * Two ways in, one path after:
5
+ *
6
+ * (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
7
+ * products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
8
+ * callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
9
+ * DROPS the event — the framework never substitutes an id of its own.
10
+ *
11
+ * (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
12
+ * buffering stay wherever the product already keeps them (operon desktop wraps its
13
+ * consent-gated main-process sink this way, so there is exactly one PostHog channel).
14
+ *
15
+ * Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
16
+ * enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
17
+ */
18
+ import { type TelemetryAppender, type TelemetryEvent, type TelemetryPrimitive } from "operon-agents-core/telemetry";
19
+ /** The minimum a sink has to look like. `posthog-node`'s `PostHog` satisfies it as-is. */
20
+ export interface PostHogClientLike {
21
+ capture(message: {
22
+ readonly distinctId?: string;
23
+ readonly event: string;
24
+ readonly properties: Record<string, TelemetryPrimitive>;
25
+ readonly timestamp?: Date;
26
+ }): void;
27
+ flush?(): Promise<void> | void;
28
+ shutdown?(timeoutMs?: number): Promise<void> | void;
29
+ }
30
+ export interface PostHogAppIdentity {
31
+ /** Product name stamped on every event as `app_name` (e.g. `operon`). */
32
+ readonly name: string;
33
+ /** Product version stamped as `app_version`. */
34
+ readonly version?: string;
35
+ }
36
+ interface PostHogAppenderBaseOptions {
37
+ readonly app: PostHogAppIdentity;
38
+ /** Who the event is about. Required in mode (a); optional in mode (b) where the client decides. */
39
+ readonly getDistinctId?: () => string | undefined;
40
+ /** Extra properties stamped on every event (a channel tag, say). Same red lines apply. */
41
+ readonly commonProperties?: Readonly<Record<string, TelemetryPrimitive>>;
42
+ /** Where dropped-event and client errors go. Default: `console.warn`, once per reason. */
43
+ readonly warn?: (message: string) => void;
44
+ }
45
+ /** Mode (a): the appender owns a `posthog-node` client. */
46
+ export interface PostHogAppenderClientOptions extends PostHogAppenderBaseOptions {
47
+ readonly apiKey: string;
48
+ /** PostHog ingestion host, e.g. `https://us.i.posthog.com`. */
49
+ readonly host: string;
50
+ readonly getDistinctId: () => string | undefined;
51
+ /** Client batching knobs; passed through. */
52
+ readonly flushAt?: number;
53
+ readonly flushIntervalMs?: number;
54
+ /** Test seam: build the client from resolved options instead of `new PostHog(...)`. */
55
+ readonly createClient?: (apiKey: string, options: {
56
+ host: string;
57
+ flushAt?: number;
58
+ flushInterval?: number;
59
+ }) => PostHogClientLike;
60
+ }
61
+ /** Mode (b): the product injects the sink. */
62
+ export interface PostHogAppenderInjectedOptions extends PostHogAppenderBaseOptions {
63
+ readonly client: PostHogClientLike;
64
+ }
65
+ export type PostHogAppenderOptions = PostHogAppenderClientOptions | PostHogAppenderInjectedOptions;
66
+ export declare class PostHogAppender implements TelemetryAppender {
67
+ private client;
68
+ private readonly ready;
69
+ private readonly getDistinctId;
70
+ private readonly requireDistinctId;
71
+ private readonly common;
72
+ private readonly warn;
73
+ private readonly warned;
74
+ /** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
75
+ private pending;
76
+ private closed;
77
+ private droppedCount;
78
+ constructor(options: PostHogAppenderOptions);
79
+ /** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
80
+ get dropped(): number;
81
+ track(event: TelemetryEvent): void;
82
+ private send;
83
+ private warnOnce;
84
+ flush(): Promise<void>;
85
+ shutdown(): Promise<void>;
86
+ }
87
+ export {};
88
+ //# sourceMappingURL=telemetry-posthog.d.ts.map
@@ -0,0 +1,142 @@
1
+ /**
2
+ * PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
3
+ *
4
+ * Two ways in, one path after:
5
+ *
6
+ * (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
7
+ * products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
8
+ * callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
9
+ * DROPS the event — the framework never substitutes an id of its own.
10
+ *
11
+ * (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
12
+ * buffering stay wherever the product already keeps them (operon desktop wraps its
13
+ * consent-gated main-process sink this way, so there is exactly one PostHog channel).
14
+ *
15
+ * Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
16
+ * enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
17
+ */
18
+ import { createRequire } from "node:module";
19
+ import { redactTelemetryProperties } from "operon-agents-core/telemetry";
20
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
21
+ function frameworkVersion() {
22
+ try {
23
+ const require = createRequire(import.meta.url);
24
+ const pkg = require("../package.json");
25
+ return pkg.version ?? "unknown";
26
+ }
27
+ catch {
28
+ return "unknown";
29
+ }
30
+ }
31
+ function isInjected(options) {
32
+ return "client" in options;
33
+ }
34
+ async function createNodeClient(apiKey, options) {
35
+ // Loaded lazily so a product in mode (b) never pays for the module, and so the import lives
36
+ // in one place if the client ever changes.
37
+ const mod = await import("posthog-node");
38
+ return new mod.PostHog(apiKey, { host: options.host, flushAt: options.flushAt, flushInterval: options.flushInterval, disableGeoip: true });
39
+ }
40
+ export class PostHogAppender {
41
+ client;
42
+ ready;
43
+ getDistinctId;
44
+ requireDistinctId;
45
+ common;
46
+ warn;
47
+ warned = new Set();
48
+ /** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
49
+ pending = [];
50
+ closed = false;
51
+ droppedCount = 0;
52
+ constructor(options) {
53
+ this.getDistinctId = options.getDistinctId;
54
+ this.requireDistinctId = !isInjected(options);
55
+ this.common = Object.freeze({
56
+ app_name: options.app.name,
57
+ app_version: options.app.version ?? null,
58
+ framework_version: frameworkVersion(),
59
+ platform: process.platform,
60
+ ...(options.commonProperties ?? {}),
61
+ });
62
+ this.warn = options.warn ?? ((message) => console.warn(`[telemetry/posthog] ${message}`));
63
+ if (isInjected(options)) {
64
+ this.client = options.client;
65
+ this.ready = Promise.resolve();
66
+ }
67
+ else {
68
+ const create = options.createClient;
69
+ const resolved = { host: options.host, flushAt: options.flushAt, flushInterval: options.flushIntervalMs };
70
+ this.ready = (create === undefined ? createNodeClient(options.apiKey, resolved) : Promise.resolve(create(options.apiKey, resolved)))
71
+ .then((client) => {
72
+ this.client = client;
73
+ const queued = this.pending;
74
+ this.pending = [];
75
+ for (const event of queued)
76
+ this.send(event);
77
+ })
78
+ .catch((error) => {
79
+ this.warnOnce("client-init", `client failed to initialise; events will be dropped: ${String(error)}`);
80
+ this.pending = [];
81
+ });
82
+ }
83
+ }
84
+ /** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
85
+ get dropped() {
86
+ return this.droppedCount;
87
+ }
88
+ track(event) {
89
+ if (this.closed) {
90
+ this.droppedCount += 1;
91
+ return;
92
+ }
93
+ if (this.client === undefined) {
94
+ if (this.pending.length >= 200) {
95
+ this.droppedCount += 1;
96
+ return;
97
+ }
98
+ this.pending.push(event);
99
+ return;
100
+ }
101
+ this.send(event);
102
+ }
103
+ send(event) {
104
+ const client = this.client;
105
+ if (client === undefined)
106
+ return;
107
+ const distinctId = this.getDistinctId?.();
108
+ if (this.requireDistinctId && distinctId === undefined) {
109
+ this.droppedCount += 1;
110
+ this.warnOnce("no-distinct-id", "getDistinctId() returned undefined; event dropped (the framework never mints an id)");
111
+ return;
112
+ }
113
+ const properties = { ...this.common, ...redactTelemetryProperties(event.properties) };
114
+ try {
115
+ client.capture({
116
+ ...(distinctId === undefined ? {} : { distinctId }),
117
+ event: event.name,
118
+ properties,
119
+ timestamp: new Date(event.timestamp),
120
+ });
121
+ }
122
+ catch (error) {
123
+ this.warnOnce("capture", `client.capture threw: ${String(error)}`);
124
+ }
125
+ }
126
+ warnOnce(key, message) {
127
+ if (this.warned.has(key))
128
+ return;
129
+ this.warned.add(key);
130
+ this.warn(message);
131
+ }
132
+ async flush() {
133
+ await this.ready;
134
+ await Promise.resolve(this.client?.flush?.());
135
+ }
136
+ async shutdown() {
137
+ await this.ready;
138
+ this.closed = true;
139
+ await Promise.resolve(this.client?.shutdown?.(SHUTDOWN_TIMEOUT_MS));
140
+ }
141
+ }
142
+ //# sourceMappingURL=telemetry-posthog.js.map
@@ -0,0 +1,4 @@
1
+ export * from "operon-agents-core/telemetry";
2
+ export { PostHogAppender } from "./telemetry-posthog.ts";
3
+ export type { PostHogAppenderOptions, PostHogAppenderClientOptions, PostHogAppenderInjectedOptions, PostHogClientLike, PostHogAppIdentity } from "./telemetry-posthog.ts";
4
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,3 @@
1
+ export * from "operon-agents-core/telemetry";
2
+ export { PostHogAppender } from "./telemetry-posthog.js";
3
+ //# sourceMappingURL=telemetry.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "operon-agents",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "description": "Public SDK entrypoint for building agents.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,6 +58,10 @@
58
58
  "types": "./dist/tracing.d.ts",
59
59
  "import": "./dist/tracing.js"
60
60
  },
61
+ "./telemetry": {
62
+ "types": "./dist/telemetry.d.ts",
63
+ "import": "./dist/telemetry.js"
64
+ },
61
65
  "./package.json": "./package.json"
62
66
  },
63
67
  "files": [
@@ -66,8 +70,9 @@
66
70
  "README.md"
67
71
  ],
68
72
  "dependencies": {
73
+ "posthog-node": "^5.51.6",
69
74
  "zod": "^4.4.3",
70
- "operon-agents-core": "0.1.0-alpha.4"
75
+ "operon-agents-core": "0.1.0-alpha.5"
71
76
  },
72
77
  "devDependencies": {
73
78
  "@earendil-works/pi-ai": "0.84.1"