@telemetry-dev/omp 0.1.0
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/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/extension-CItLaHEs.mjs +316 -0
- package/dist/extension-o2etehO0.d.mts +59 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/register.d.mts +11 -0
- package/dist/register.mjs +10 -0
- package/package.json +78 -0
- package/src/config.ts +37 -0
- package/src/extension.ts +482 -0
- package/src/index.ts +8 -0
- package/src/register.ts +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 telemetry.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @telemetry-dev/omp
|
|
2
|
+
|
|
3
|
+
Telemetry integration for the [Oh My Pi (omp)](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
|
|
4
|
+
coding agent and [telemetry.dev](https://telemetry.dev). It records omp agent loops, model calls,
|
|
5
|
+
tool executions, and lifecycle events without changing omp's behavior.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Create `~/.omp/agent/extensions/telemetry-dev.ts` (global) or
|
|
10
|
+
`.omp/extensions/telemetry-dev.ts` (project):
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { telemetryDevExtension } from "@telemetry-dev/omp";
|
|
14
|
+
export default telemetryDevExtension();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Install `@telemetry-dev/omp` somewhere that file can resolve it. The package also ships an
|
|
18
|
+
`omp.extensions` manifest pointing at `@telemetry-dev/omp/register`, so installing it as an omp
|
|
19
|
+
package loads the extension automatically with environment-driven configuration.
|
|
20
|
+
|
|
21
|
+
The integration targets `@oh-my-pi/pi-coding-agent` 17.x. The omp package is an optional peer
|
|
22
|
+
dependency because omp bundles its extension API and provides it to loaded extensions.
|
|
23
|
+
|
|
24
|
+
## Environment
|
|
25
|
+
|
|
26
|
+
| Variable | Required | Default | Notes |
|
|
27
|
+
| --------------------------- | -------- | ------------------------------ | ----------------------------------------------------------------------- |
|
|
28
|
+
| `TELEMETRY_DEV_API_KEY` | yes | — | Ingest key (`td_live_…`). No key ⇒ the integration is a complete no-op. |
|
|
29
|
+
| `TELEMETRY_DEV_BASE_URL` | no | `https://ingest.telemetry.dev` | Trailing slashes are stripped. |
|
|
30
|
+
| `TELEMETRY_DEV_ENVIRONMENT` | no | `production` | Environment label on every trace/log. |
|
|
31
|
+
| `OTEL_SERVICE_NAME` | no | `omp` | OpenTelemetry service name. |
|
|
32
|
+
|
|
33
|
+
All four are also settable through SDK options when loading the extension directly:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { telemetryDevExtension } from "@telemetry-dev/omp";
|
|
37
|
+
|
|
38
|
+
export default telemetryDevExtension({
|
|
39
|
+
agentName: "pair-programmer",
|
|
40
|
+
captureInput: false,
|
|
41
|
+
captureOutput: true,
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Initialization is one-shot; the first extension factory initialized in a process supplies the SDK
|
|
46
|
+
options.
|
|
47
|
+
|
|
48
|
+
## Trace shape
|
|
49
|
+
|
|
50
|
+
A typical prompt produces this hierarchy:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
invoke_agent
|
|
54
|
+
├── chat {model} (assistant message)
|
|
55
|
+
│ ├── execute_tool {toolName} (tool calls issued by that message)
|
|
56
|
+
│ └── execute_tool {toolName}
|
|
57
|
+
└── chat {model} (final assistant message)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- one `invoke_agent` span for the full agent loop, including the prompt and final assistant text;
|
|
61
|
+
- one nested `chat {model}` span per completed assistant message, with provider, model, response id,
|
|
62
|
+
finish reason, usage (including cache and reasoning tokens), TTFT, and provider-reported duration;
|
|
63
|
+
- one `execute_tool {toolName}` span per tool execution, nested under the `chat` span whose tool
|
|
64
|
+
call issued it (tool call ids are matched against the assistant message's tool-call blocks;
|
|
65
|
+
executions with no matching message fall back to the `invoke_agent` span), including the tool
|
|
66
|
+
call id, arguments, result, and error state;
|
|
67
|
+
- lifecycle logs for sessions, turns, compaction, and auto-retry.
|
|
68
|
+
|
|
69
|
+
Every span and log reads `ctx.sessionManager.getSessionId()` when its event fires and records it as
|
|
70
|
+
`gen_ai.conversation.id`, so session switches, branches, and tree navigation attach telemetry to the
|
|
71
|
+
correct session. Logs also carry `gen_ai.agent.name` and the raw omp event type as `eventName`.
|
|
72
|
+
|
|
73
|
+
`agent_end` closes any unfinished tool spans and starts an asynchronous flush. `session_shutdown`
|
|
74
|
+
closes an unfinished agent loop and awaits a final flush before the process exits.
|
|
75
|
+
|
|
76
|
+
Chat spans use omp's own message timestamps: start = `message.timestamp`, end = start +
|
|
77
|
+
`message.duration`, so span timing matches provider-reported request duration rather than local
|
|
78
|
+
event-dispatch time.
|
|
79
|
+
|
|
80
|
+
## Content capture
|
|
81
|
+
|
|
82
|
+
Prompt input, assistant text output, tool arguments, and tool results follow the telemetry.dev SDK
|
|
83
|
+
`captureInput` and `captureOutput` settings. Both default to `true`. Use `mask` to redact values
|
|
84
|
+
before capture and `maxAttributeLength` to bound serialized attributes.
|
|
85
|
+
|
|
86
|
+
Thinking and tool-call content blocks are not copied into assistant output; only text blocks are
|
|
87
|
+
joined. Usage metadata still includes reasoning-token counts when omp reports them.
|
|
88
|
+
|
|
89
|
+
## Limitations
|
|
90
|
+
|
|
91
|
+
- `tool_call` and `tool_result` are intentionally not intercepted. In omp, an uncaught `tool_call`
|
|
92
|
+
handler error blocks the tool (fail-closed), so this integration uses the fail-open
|
|
93
|
+
`tool_execution_start` / `tool_execution_end` events instead.
|
|
94
|
+
- An assistant message with `stopReason: "aborted"` is recorded with finish reason `aborted`, not as
|
|
95
|
+
an error. Only `stopReason: "error"` marks chat and agent spans as errors.
|
|
96
|
+
- omp does not report client-side cost; telemetry.dev computes cost server-side from usage and
|
|
97
|
+
pricing data.
|
|
98
|
+
- Subagents (task tool) run in separate processes with their own extension instances, so their
|
|
99
|
+
telemetry appears as separate sessions.
|
|
100
|
+
- Initialization is one-shot. Loading the extension more than once does not replace the first
|
|
101
|
+
configuration.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { flush, init, log, startSpan } from "@telemetry-dev/sdk";
|
|
2
|
+
//#region src/config.ts
|
|
3
|
+
let initialized = false;
|
|
4
|
+
/** Initializes the telemetry.dev SDK exactly once per process for this integration. */
|
|
5
|
+
function ensureInit(options = {}, overrides) {
|
|
6
|
+
if (initialized) return;
|
|
7
|
+
initialized = true;
|
|
8
|
+
init({
|
|
9
|
+
...options,
|
|
10
|
+
serviceName: options.serviceName ?? process.env.OTEL_SERVICE_NAME ?? "omp",
|
|
11
|
+
registerGlobal: false
|
|
12
|
+
}, overrides);
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/extension.ts
|
|
16
|
+
function asRecord(value) {
|
|
17
|
+
if (value === null || Array.isArray(value) || !(value instanceof Object)) return void 0;
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function stringField(record, key) {
|
|
21
|
+
const value = record?.[key];
|
|
22
|
+
if (value === void 0 || value === null || value.constructor !== String || value === "") return void 0;
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function numberField(record, key) {
|
|
26
|
+
const value = record?.[key];
|
|
27
|
+
if (value === void 0 || value === null || value.constructor !== Number) return void 0;
|
|
28
|
+
const number = value;
|
|
29
|
+
return Number.isFinite(number) ? number : void 0;
|
|
30
|
+
}
|
|
31
|
+
function booleanField(record, key) {
|
|
32
|
+
const value = record?.[key];
|
|
33
|
+
if (value === void 0 || value === null || value.constructor !== Boolean) return void 0;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function reportError(onError, cause) {
|
|
37
|
+
try {
|
|
38
|
+
onError?.(cause instanceof Error ? cause : new Error(String(cause)));
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
function sessionId(ctx) {
|
|
42
|
+
const id = ctx.sessionManager.getSessionId();
|
|
43
|
+
return id && id.length > 0 ? id : void 0;
|
|
44
|
+
}
|
|
45
|
+
/** Joins the text blocks of an omp message content array. */
|
|
46
|
+
function textContent(content) {
|
|
47
|
+
if (!Array.isArray(content)) return void 0;
|
|
48
|
+
const parts = [];
|
|
49
|
+
for (const block of content) {
|
|
50
|
+
const record = asRecord(block);
|
|
51
|
+
const text = record?.text;
|
|
52
|
+
if (record?.type === "text" && text !== void 0 && text !== null && text.constructor === String) parts.push(text);
|
|
53
|
+
}
|
|
54
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
55
|
+
}
|
|
56
|
+
function usageFields(message) {
|
|
57
|
+
const usage = asRecord(message.usage);
|
|
58
|
+
if (!usage) return void 0;
|
|
59
|
+
return {
|
|
60
|
+
inputTokens: numberField(usage, "input"),
|
|
61
|
+
outputTokens: numberField(usage, "output"),
|
|
62
|
+
totalTokens: numberField(usage, "totalTokens"),
|
|
63
|
+
cacheReadInputTokens: numberField(usage, "cacheRead"),
|
|
64
|
+
cacheCreationInputTokens: numberField(usage, "cacheWrite"),
|
|
65
|
+
reasoningOutputTokens: numberField(usage, "reasoningTokens")
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function assistantMessageKey(message) {
|
|
69
|
+
const timestamp = numberField(message, "timestamp");
|
|
70
|
+
if (timestamp === void 0) return void 0;
|
|
71
|
+
return JSON.stringify([
|
|
72
|
+
timestamp,
|
|
73
|
+
stringField(message, "provider") ?? "",
|
|
74
|
+
stringField(message, "model") ?? "",
|
|
75
|
+
stringField(message, "responseId") ?? "",
|
|
76
|
+
stringField(message, "stopReason") ?? ""
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
/** Named error so `error.type` reflects the failure class instead of "Error". */
|
|
80
|
+
function failureError(name, message) {
|
|
81
|
+
const error = new Error(message ?? name);
|
|
82
|
+
error.name = name;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates an omp extension factory that exports agent loops, model calls, and
|
|
87
|
+
* tool executions to telemetry.dev.
|
|
88
|
+
*
|
|
89
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
90
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
91
|
+
* tool execution. Session lifecycle, compaction, and retry events are emitted
|
|
92
|
+
* as logs joined via `gen_ai.conversation.id`.
|
|
93
|
+
*/
|
|
94
|
+
function telemetryDevExtension(options = {}, overrides) {
|
|
95
|
+
const { agentName = "omp", ...sdkOptions } = options;
|
|
96
|
+
const onError = sdkOptions.onError;
|
|
97
|
+
return (pi) => {
|
|
98
|
+
try {
|
|
99
|
+
ensureInit(sdkOptions, overrides);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
reportError(onError, error);
|
|
102
|
+
}
|
|
103
|
+
/** Calls through `pi` so the host's `on` keeps its `this` binding. */
|
|
104
|
+
function register(event, handler) {
|
|
105
|
+
pi.on.bind(pi)(event, handler);
|
|
106
|
+
}
|
|
107
|
+
let agentSpan;
|
|
108
|
+
/** Stable key of the latest assistant message emitted for the active agent span. */
|
|
109
|
+
let agentMessageKey;
|
|
110
|
+
let pendingPrompt;
|
|
111
|
+
/** Chat span that issued each pending tool call, so tool spans nest under it. */
|
|
112
|
+
const chatSpanByToolCall = /* @__PURE__ */ new Map();
|
|
113
|
+
const toolSpans = /* @__PURE__ */ new Map();
|
|
114
|
+
function baseAttributes(ctx) {
|
|
115
|
+
return {
|
|
116
|
+
"gen_ai.conversation.id": sessionId(ctx),
|
|
117
|
+
"gen_ai.agent.name": agentName
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function emit(eventName, ctx, level, message, attributes = {}) {
|
|
121
|
+
log(message, {
|
|
122
|
+
level,
|
|
123
|
+
eventName,
|
|
124
|
+
attributes: {
|
|
125
|
+
...baseAttributes(ctx),
|
|
126
|
+
...attributes
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function spanAttributes(ctx) {
|
|
131
|
+
const id = sessionId(ctx);
|
|
132
|
+
return id ? { "gen_ai.conversation.id": id } : {};
|
|
133
|
+
}
|
|
134
|
+
function endDanglingToolSpans() {
|
|
135
|
+
for (const span of toolSpans.values()) span.end({ error: failureError("incomplete", "tool execution did not complete") });
|
|
136
|
+
toolSpans.clear();
|
|
137
|
+
chatSpanByToolCall.clear();
|
|
138
|
+
}
|
|
139
|
+
function endAgentSpan(fields) {
|
|
140
|
+
endDanglingToolSpans();
|
|
141
|
+
agentSpan?.end(fields);
|
|
142
|
+
agentSpan = void 0;
|
|
143
|
+
agentMessageKey = void 0;
|
|
144
|
+
}
|
|
145
|
+
function on(event, handler) {
|
|
146
|
+
register(event, (payload, ctx) => {
|
|
147
|
+
try {
|
|
148
|
+
return handler(payload, ctx);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
reportError(onError, error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
register("before_agent_start", (event) => {
|
|
156
|
+
try {
|
|
157
|
+
pendingPrompt = stringField(event, "prompt");
|
|
158
|
+
} catch (error) {
|
|
159
|
+
reportError(onError, error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
on("agent_start", (_event, ctx) => {
|
|
163
|
+
if (agentSpan) {
|
|
164
|
+
if (pendingPrompt === void 0) return;
|
|
165
|
+
endAgentSpan({ finishReason: "incomplete" });
|
|
166
|
+
}
|
|
167
|
+
agentSpan = startSpan("invoke_agent", {
|
|
168
|
+
type: "agent",
|
|
169
|
+
agentName,
|
|
170
|
+
input: pendingPrompt,
|
|
171
|
+
attributes: spanAttributes(ctx)
|
|
172
|
+
});
|
|
173
|
+
pendingPrompt = void 0;
|
|
174
|
+
});
|
|
175
|
+
on("agent_end", (event, _ctx) => {
|
|
176
|
+
if (!agentSpan) return;
|
|
177
|
+
if (event.willContinue === true) {
|
|
178
|
+
flush();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
182
|
+
let lastAssistant;
|
|
183
|
+
for (const message of messages) {
|
|
184
|
+
const record = asRecord(message);
|
|
185
|
+
if (record?.role === "assistant") lastAssistant = record;
|
|
186
|
+
}
|
|
187
|
+
if (lastAssistant) {
|
|
188
|
+
const eventMessageKey = assistantMessageKey(lastAssistant);
|
|
189
|
+
if (eventMessageKey === void 0 || eventMessageKey !== agentMessageKey) {
|
|
190
|
+
flush();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const stopReason = stringField(lastAssistant, "stopReason");
|
|
195
|
+
const errorMessage = stringField(lastAssistant, "errorMessage");
|
|
196
|
+
endAgentSpan({
|
|
197
|
+
output: textContent(lastAssistant?.content),
|
|
198
|
+
finishReason: stopReason,
|
|
199
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : void 0
|
|
200
|
+
});
|
|
201
|
+
flush();
|
|
202
|
+
});
|
|
203
|
+
on("message_end", (event, ctx) => {
|
|
204
|
+
const message = asRecord(event.message);
|
|
205
|
+
if (message?.role !== "assistant") return;
|
|
206
|
+
if (agentSpan) agentMessageKey = assistantMessageKey(message);
|
|
207
|
+
const model = stringField(message, "model");
|
|
208
|
+
const startTime = numberField(message, "timestamp");
|
|
209
|
+
const duration = numberField(message, "duration");
|
|
210
|
+
const errorMessage = stringField(message, "errorMessage");
|
|
211
|
+
const stopReason = stringField(message, "stopReason");
|
|
212
|
+
const span = startSpan(model ? `chat ${model}` : "chat", {
|
|
213
|
+
type: "generation",
|
|
214
|
+
parent: agentSpan,
|
|
215
|
+
startTime,
|
|
216
|
+
model,
|
|
217
|
+
provider: stringField(message, "provider"),
|
|
218
|
+
responseId: stringField(message, "responseId"),
|
|
219
|
+
usage: usageFields(message),
|
|
220
|
+
finishReason: stopReason,
|
|
221
|
+
timeToFirstChunkMs: numberField(message, "ttft"),
|
|
222
|
+
output: textContent(message.content),
|
|
223
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : void 0,
|
|
224
|
+
attributes: spanAttributes(ctx)
|
|
225
|
+
});
|
|
226
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
227
|
+
const record = asRecord(block);
|
|
228
|
+
const id = stringField(record, "id");
|
|
229
|
+
if (record?.type === "toolCall" && id !== void 0) chatSpanByToolCall.set(id, span);
|
|
230
|
+
}
|
|
231
|
+
span.end({ endTime: startTime !== void 0 && duration !== void 0 ? startTime + duration : void 0 });
|
|
232
|
+
});
|
|
233
|
+
on("tool_execution_start", (event, ctx) => {
|
|
234
|
+
const span = startSpan(`execute_tool ${event.toolName}`, {
|
|
235
|
+
type: "tool",
|
|
236
|
+
parent: chatSpanByToolCall.get(event.toolCallId) ?? agentSpan,
|
|
237
|
+
toolName: event.toolName,
|
|
238
|
+
toolCallId: event.toolCallId,
|
|
239
|
+
input: event.args,
|
|
240
|
+
attributes: spanAttributes(ctx)
|
|
241
|
+
});
|
|
242
|
+
chatSpanByToolCall.delete(event.toolCallId);
|
|
243
|
+
toolSpans.set(event.toolCallId, span);
|
|
244
|
+
});
|
|
245
|
+
on("tool_execution_end", (event, _ctx) => {
|
|
246
|
+
const span = toolSpans.get(event.toolCallId);
|
|
247
|
+
if (!span) return;
|
|
248
|
+
toolSpans.delete(event.toolCallId);
|
|
249
|
+
const resultText = textContent(asRecord(event.result)?.content);
|
|
250
|
+
span.end({
|
|
251
|
+
output: event.result,
|
|
252
|
+
error: event.isError ? failureError("ToolExecutionError", resultText) : void 0
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
on("turn_start", (event, ctx) => {
|
|
256
|
+
emit("turn_start", ctx, "debug", "Turn started", { "omp.turn.index": event.turnIndex });
|
|
257
|
+
});
|
|
258
|
+
on("turn_end", (event, ctx) => {
|
|
259
|
+
emit("turn_end", ctx, "debug", "Turn completed", { "omp.turn.index": event.turnIndex });
|
|
260
|
+
});
|
|
261
|
+
on("session_start", (_event, ctx) => {
|
|
262
|
+
const model = asRecord(ctx.model);
|
|
263
|
+
emit("session_start", ctx, "info", "Session started", {
|
|
264
|
+
"gen_ai.request.model": stringField(model, "id"),
|
|
265
|
+
"gen_ai.provider.name": stringField(model, "provider"),
|
|
266
|
+
"omp.cwd": ctx.cwd
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
on("session_switch", (_event, ctx) => {
|
|
270
|
+
emit("session_switch", ctx, "info", "Session switched");
|
|
271
|
+
});
|
|
272
|
+
on("session_branch", (_event, ctx) => {
|
|
273
|
+
emit("session_branch", ctx, "info", "Session branched");
|
|
274
|
+
});
|
|
275
|
+
on("session_compact", (_event, ctx) => {
|
|
276
|
+
emit("session_compact", ctx, "info", "Session compacted");
|
|
277
|
+
});
|
|
278
|
+
on("auto_compaction_start", (event, ctx) => {
|
|
279
|
+
emit("auto_compaction_start", ctx, "info", `Auto-compaction started (${event.reason})`, {
|
|
280
|
+
"omp.compaction.reason": event.reason,
|
|
281
|
+
"omp.compaction.action": event.action
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
on("auto_compaction_end", (event, ctx) => {
|
|
285
|
+
const record = asRecord(event);
|
|
286
|
+
const errorMessage = stringField(record, "errorMessage");
|
|
287
|
+
emit("auto_compaction_end", ctx, errorMessage ? "error" : "info", errorMessage ? `Auto-compaction failed: ${errorMessage}` : "Auto-compaction completed", {
|
|
288
|
+
"omp.compaction.action": event.action,
|
|
289
|
+
"omp.compaction.aborted": booleanField(record, "aborted"),
|
|
290
|
+
"omp.compaction.skipped": booleanField(record, "skipped")
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
on("auto_retry_start", (event, ctx) => {
|
|
294
|
+
emit("auto_retry_start", ctx, "warn", `Auto-retry attempt ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`, {
|
|
295
|
+
"omp.retry.attempt": event.attempt,
|
|
296
|
+
"omp.retry.max_attempts": event.maxAttempts,
|
|
297
|
+
"omp.retry.delay_ms": event.delayMs
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
on("auto_retry_end", (event, ctx) => {
|
|
301
|
+
const finalError = stringField(asRecord(event), "finalError");
|
|
302
|
+
emit("auto_retry_end", ctx, event.success ? "info" : "error", event.success ? `Auto-retry succeeded after ${event.attempt} attempt(s)` : `Auto-retry failed: ${finalError ?? "unknown error"}`, { "omp.retry.attempt": event.attempt });
|
|
303
|
+
});
|
|
304
|
+
register("session_shutdown", async (_event, ctx) => {
|
|
305
|
+
try {
|
|
306
|
+
if (agentSpan) endAgentSpan({ finishReason: "incomplete" });
|
|
307
|
+
emit("session_shutdown", ctx, "info", "Session shutdown");
|
|
308
|
+
await flush();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
reportError(onError, error);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
export { telemetryDevExtension as t };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ClientOverrides, TelemetryOptions } from "@telemetry-dev/sdk";
|
|
2
|
+
|
|
3
|
+
//#region src/config.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* SDK options accepted by @telemetry-dev/omp. `registerGlobal` is excluded on
|
|
6
|
+
* purpose: omp runs its own OpenTelemetry pipeline in-process, so this
|
|
7
|
+
* integration never touches the global provider — every span is created
|
|
8
|
+
* through the SDK's own tracer with explicit parenting.
|
|
9
|
+
*/
|
|
10
|
+
type TelemetryDevOmpOptions = Omit<TelemetryOptions, "registerGlobal">;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/extension.d.ts
|
|
13
|
+
/** Options for {@link telemetryDevExtension}. */
|
|
14
|
+
interface TelemetryDevExtensionOptions extends TelemetryDevOmpOptions {
|
|
15
|
+
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"omp"`. */
|
|
16
|
+
agentName?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Structural slice of omp's `ExtensionContext` read by this integration.
|
|
20
|
+
*
|
|
21
|
+
* The host package (`@oh-my-pi/pi-coding-agent`) is an optional peer, so its
|
|
22
|
+
* types must not appear in this package's emitted declarations; assignability
|
|
23
|
+
* to the real host types is asserted in this package's tests.
|
|
24
|
+
*/
|
|
25
|
+
interface TelemetryDevExtensionContext {
|
|
26
|
+
/** Current working directory. */
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Current model descriptor, when one is selected. */
|
|
29
|
+
model: {
|
|
30
|
+
id?: string;
|
|
31
|
+
provider?: string;
|
|
32
|
+
} | undefined;
|
|
33
|
+
/** Read-only session manager exposing the session id. */
|
|
34
|
+
sessionManager: {
|
|
35
|
+
getSessionId(): string | undefined;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Structural stand-in for the host `ExtensionAPI` passed to extension
|
|
40
|
+
* factories. The `never` parameters make any host event-subscription surface
|
|
41
|
+
* assignable; this integration is not meant to be called through this type.
|
|
42
|
+
*/
|
|
43
|
+
interface TelemetryDevExtensionHost {
|
|
44
|
+
on(event: never, handler: never): void;
|
|
45
|
+
}
|
|
46
|
+
/** Structural stand-in for the host `ExtensionFactory` type. */
|
|
47
|
+
type TelemetryDevExtension = (pi: TelemetryDevExtensionHost) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Creates an omp extension factory that exports agent loops, model calls, and
|
|
50
|
+
* tool executions to telemetry.dev.
|
|
51
|
+
*
|
|
52
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
53
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
54
|
+
* tool execution. Session lifecycle, compaction, and retry events are emitted
|
|
55
|
+
* as logs joined via `gen_ai.conversation.id`.
|
|
56
|
+
*/
|
|
57
|
+
declare function telemetryDevExtension(options?: TelemetryDevExtensionOptions, overrides?: ClientOverrides): TelemetryDevExtension;
|
|
58
|
+
//#endregion
|
|
59
|
+
export { telemetryDevExtension as a, TelemetryDevExtensionOptions as i, TelemetryDevExtensionContext as n, TelemetryDevOmpOptions as o, TelemetryDevExtensionHost as r, TelemetryDevExtension as t };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as telemetryDevExtension, i as TelemetryDevExtensionOptions, n as TelemetryDevExtensionContext, o as TelemetryDevOmpOptions, r as TelemetryDevExtensionHost, t as TelemetryDevExtension } from "./extension-o2etehO0.mjs";
|
|
2
|
+
export { type TelemetryDevExtension, type TelemetryDevExtensionContext, type TelemetryDevExtensionHost, type TelemetryDevExtensionOptions, type TelemetryDevOmpOptions, telemetryDevExtension };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { t as TelemetryDevExtension } from "./extension-o2etehO0.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/register.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Ready-to-load omp extension entry configured from `TELEMETRY_DEV_*`
|
|
6
|
+
* environment variables. Referenced by this package's `omp.extensions`
|
|
7
|
+
* manifest so `@telemetry-dev/omp` works as an installed omp package.
|
|
8
|
+
*/
|
|
9
|
+
declare const _default: TelemetryDevExtension;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { _default as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { t as telemetryDevExtension } from "./extension-CItLaHEs.mjs";
|
|
2
|
+
//#region src/register.ts
|
|
3
|
+
/**
|
|
4
|
+
* Ready-to-load omp extension entry configured from `TELEMETRY_DEV_*`
|
|
5
|
+
* environment variables. Referenced by this package's `omp.extensions`
|
|
6
|
+
* manifest so `@telemetry-dev/omp` works as an installed omp package.
|
|
7
|
+
*/
|
|
8
|
+
var register_default = telemetryDevExtension();
|
|
9
|
+
//#endregion
|
|
10
|
+
export { register_default as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telemetry-dev/omp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Oh My Pi (omp) coding-agent telemetry integration for telemetry.dev.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"coding-agent",
|
|
8
|
+
"genai",
|
|
9
|
+
"llm",
|
|
10
|
+
"observability",
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"opentelemetry",
|
|
14
|
+
"telemetry",
|
|
15
|
+
"tracing"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://telemetry.dev",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/telemetry-dev/telemetry.dev.git",
|
|
22
|
+
"directory": "packages/omp"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"import": "./dist/index.mjs",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./register": {
|
|
36
|
+
"types": "./dist/register.d.mts",
|
|
37
|
+
"import": "./dist/register.mjs",
|
|
38
|
+
"default": "./dist/register.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@opentelemetry/api": "^1.9.1",
|
|
47
|
+
"@telemetry-dev/sdk": "^0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@oh-my-pi/pi-coding-agent": "17.1.8",
|
|
51
|
+
"@opentelemetry/sdk-logs": "^0.218.0",
|
|
52
|
+
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
53
|
+
"@types/node": "^25.5.0",
|
|
54
|
+
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
|
55
|
+
"typescript": "^6.0.2",
|
|
56
|
+
"vite-plus": "0.1.20",
|
|
57
|
+
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.20"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@oh-my-pi/pi-coding-agent": ">=17"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@oh-my-pi/pi-coding-agent": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"omp": {
|
|
68
|
+
"extensions": [
|
|
69
|
+
"./dist/register.mjs"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "pnpm exec vp pack",
|
|
74
|
+
"dev": "pnpm exec vp pack --watch",
|
|
75
|
+
"test": "vp test",
|
|
76
|
+
"check": "vp check"
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { init, shutdown, type ClientOverrides, type TelemetryOptions } from "@telemetry-dev/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SDK options accepted by @telemetry-dev/omp. `registerGlobal` is excluded on
|
|
5
|
+
* purpose: omp runs its own OpenTelemetry pipeline in-process, so this
|
|
6
|
+
* integration never touches the global provider — every span is created
|
|
7
|
+
* through the SDK's own tracer with explicit parenting.
|
|
8
|
+
*/
|
|
9
|
+
export type TelemetryDevOmpOptions = Omit<TelemetryOptions, "registerGlobal">;
|
|
10
|
+
|
|
11
|
+
export type { ClientOverrides };
|
|
12
|
+
|
|
13
|
+
let initialized = false;
|
|
14
|
+
|
|
15
|
+
/** Initializes the telemetry.dev SDK exactly once per process for this integration. */
|
|
16
|
+
export function ensureInit(
|
|
17
|
+
options: TelemetryDevOmpOptions = {},
|
|
18
|
+
overrides?: ClientOverrides,
|
|
19
|
+
): void {
|
|
20
|
+
if (initialized) return;
|
|
21
|
+
initialized = true;
|
|
22
|
+
init(
|
|
23
|
+
{
|
|
24
|
+
...options,
|
|
25
|
+
serviceName: options.serviceName ?? process.env.OTEL_SERVICE_NAME ?? "omp",
|
|
26
|
+
// Enforce the guarantee above at runtime for untyped (plain JS) callers.
|
|
27
|
+
registerGlobal: false,
|
|
28
|
+
},
|
|
29
|
+
overrides,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test seam for unit tests that need a fresh SDK singleton. */
|
|
34
|
+
export async function resetForTesting(): Promise<void> {
|
|
35
|
+
initialized = false;
|
|
36
|
+
await shutdown();
|
|
37
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import {
|
|
2
|
+
flush,
|
|
3
|
+
log,
|
|
4
|
+
startSpan,
|
|
5
|
+
type LogLevel,
|
|
6
|
+
type SpanHandle,
|
|
7
|
+
type TokenUsage,
|
|
8
|
+
} from "@telemetry-dev/sdk";
|
|
9
|
+
|
|
10
|
+
import { ensureInit, type ClientOverrides, type TelemetryDevOmpOptions } from "./config.ts";
|
|
11
|
+
|
|
12
|
+
/** Options for {@link telemetryDevExtension}. */
|
|
13
|
+
export interface TelemetryDevExtensionOptions extends TelemetryDevOmpOptions {
|
|
14
|
+
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"omp"`. */
|
|
15
|
+
agentName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Structural slice of omp's `ExtensionContext` read by this integration.
|
|
20
|
+
*
|
|
21
|
+
* The host package (`@oh-my-pi/pi-coding-agent`) is an optional peer, so its
|
|
22
|
+
* types must not appear in this package's emitted declarations; assignability
|
|
23
|
+
* to the real host types is asserted in this package's tests.
|
|
24
|
+
*/
|
|
25
|
+
export interface TelemetryDevExtensionContext {
|
|
26
|
+
/** Current working directory. */
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Current model descriptor, when one is selected. */
|
|
29
|
+
model: { id?: string; provider?: string } | undefined;
|
|
30
|
+
/** Read-only session manager exposing the session id. */
|
|
31
|
+
sessionManager: { getSessionId(): string | undefined };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Structural stand-in for the host `ExtensionAPI` passed to extension
|
|
36
|
+
* factories. The `never` parameters make any host event-subscription surface
|
|
37
|
+
* assignable; this integration is not meant to be called through this type.
|
|
38
|
+
*/
|
|
39
|
+
export interface TelemetryDevExtensionHost {
|
|
40
|
+
on(event: never, handler: never): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structural stand-in for the host `ExtensionFactory` type. */
|
|
44
|
+
export type TelemetryDevExtension = (pi: TelemetryDevExtensionHost) => void;
|
|
45
|
+
|
|
46
|
+
type JsonValue =
|
|
47
|
+
| string
|
|
48
|
+
| number
|
|
49
|
+
| boolean
|
|
50
|
+
| null
|
|
51
|
+
| undefined
|
|
52
|
+
| JsonValue[]
|
|
53
|
+
| { [key: string]: JsonValue };
|
|
54
|
+
type Attrs = { [key: string]: JsonValue };
|
|
55
|
+
type JsonRecord = { [key: string]: JsonValue };
|
|
56
|
+
|
|
57
|
+
function asRecord(value: JsonValue): JsonRecord | undefined {
|
|
58
|
+
if (value === null || Array.isArray(value) || !(value instanceof Object)) return undefined;
|
|
59
|
+
return value as JsonRecord;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stringField(record: JsonRecord | undefined, key: string): string | undefined {
|
|
63
|
+
const value = record?.[key];
|
|
64
|
+
if (value === undefined || value === null || value.constructor !== String || value === "")
|
|
65
|
+
return undefined;
|
|
66
|
+
return value as string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function numberField(record: JsonRecord | undefined, key: string): number | undefined {
|
|
70
|
+
const value = record?.[key];
|
|
71
|
+
if (value === undefined || value === null || value.constructor !== Number) return undefined;
|
|
72
|
+
const number = value as number;
|
|
73
|
+
return Number.isFinite(number) ? number : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function booleanField(record: JsonRecord | undefined, key: string): boolean | undefined {
|
|
77
|
+
const value = record?.[key];
|
|
78
|
+
if (value === undefined || value === null || value.constructor !== Boolean) return undefined;
|
|
79
|
+
return value as boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reportError(onError: ((error: Error) => void) | undefined, cause: unknown): void {
|
|
83
|
+
try {
|
|
84
|
+
onError?.(cause instanceof Error ? cause : new Error(String(cause)));
|
|
85
|
+
} catch {
|
|
86
|
+
// telemetry must never fail an omp session.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sessionId(ctx: TelemetryDevExtensionContext): string | undefined {
|
|
91
|
+
const id = ctx.sessionManager.getSessionId();
|
|
92
|
+
return id && id.length > 0 ? id : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Joins the text blocks of an omp message content array. */
|
|
96
|
+
function textContent(content: JsonValue): string | undefined {
|
|
97
|
+
if (!Array.isArray(content)) return undefined;
|
|
98
|
+
const parts: string[] = [];
|
|
99
|
+
for (const block of content) {
|
|
100
|
+
const record = asRecord(block);
|
|
101
|
+
const text = record?.text;
|
|
102
|
+
if (
|
|
103
|
+
record?.type === "text" &&
|
|
104
|
+
text !== undefined &&
|
|
105
|
+
text !== null &&
|
|
106
|
+
text.constructor === String
|
|
107
|
+
) {
|
|
108
|
+
parts.push(text as string);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function usageFields(message: JsonRecord): TokenUsage | undefined {
|
|
115
|
+
const usage = asRecord(message.usage);
|
|
116
|
+
if (!usage) return undefined;
|
|
117
|
+
return {
|
|
118
|
+
inputTokens: numberField(usage, "input"),
|
|
119
|
+
outputTokens: numberField(usage, "output"),
|
|
120
|
+
totalTokens: numberField(usage, "totalTokens"),
|
|
121
|
+
cacheReadInputTokens: numberField(usage, "cacheRead"),
|
|
122
|
+
cacheCreationInputTokens: numberField(usage, "cacheWrite"),
|
|
123
|
+
reasoningOutputTokens: numberField(usage, "reasoningTokens"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assistantMessageKey(message: JsonRecord): string | undefined {
|
|
128
|
+
const timestamp = numberField(message, "timestamp");
|
|
129
|
+
if (timestamp === undefined) return undefined;
|
|
130
|
+
return JSON.stringify([
|
|
131
|
+
timestamp,
|
|
132
|
+
stringField(message, "provider") ?? "",
|
|
133
|
+
stringField(message, "model") ?? "",
|
|
134
|
+
stringField(message, "responseId") ?? "",
|
|
135
|
+
stringField(message, "stopReason") ?? "",
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Named error so `error.type` reflects the failure class instead of "Error". */
|
|
140
|
+
function failureError(name: string, message: string | undefined): Error {
|
|
141
|
+
const error = new Error(message ?? name);
|
|
142
|
+
error.name = name;
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Creates an omp extension factory that exports agent loops, model calls, and
|
|
148
|
+
* tool executions to telemetry.dev.
|
|
149
|
+
*
|
|
150
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
151
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
152
|
+
* tool execution. Session lifecycle, compaction, and retry events are emitted
|
|
153
|
+
* as logs joined via `gen_ai.conversation.id`.
|
|
154
|
+
*/
|
|
155
|
+
export function telemetryDevExtension(
|
|
156
|
+
options: TelemetryDevExtensionOptions = {},
|
|
157
|
+
overrides?: ClientOverrides,
|
|
158
|
+
): TelemetryDevExtension {
|
|
159
|
+
const { agentName = "omp", ...sdkOptions } = options;
|
|
160
|
+
const onError = sdkOptions.onError;
|
|
161
|
+
|
|
162
|
+
return (pi) => {
|
|
163
|
+
try {
|
|
164
|
+
ensureInit(sdkOptions, overrides);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
reportError(onError, error);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Calls through `pi` so the host's `on` keeps its `this` binding. */
|
|
170
|
+
function register<E extends JsonValue>(
|
|
171
|
+
event: string,
|
|
172
|
+
handler: (event: E, ctx: TelemetryDevExtensionContext) => void | Promise<void>,
|
|
173
|
+
): void {
|
|
174
|
+
const registerEvent = pi.on.bind(pi) as (
|
|
175
|
+
event: string,
|
|
176
|
+
handler: (event: E, ctx: TelemetryDevExtensionContext) => void | Promise<void>,
|
|
177
|
+
) => void;
|
|
178
|
+
registerEvent(event, handler);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let agentSpan: SpanHandle | undefined;
|
|
182
|
+
/** Stable key of the latest assistant message emitted for the active agent span. */
|
|
183
|
+
let agentMessageKey: string | undefined;
|
|
184
|
+
let pendingPrompt: string | undefined;
|
|
185
|
+
/** Chat span that issued each pending tool call, so tool spans nest under it. */
|
|
186
|
+
const chatSpanByToolCall = new Map<string, SpanHandle>();
|
|
187
|
+
const toolSpans = new Map<string, SpanHandle>();
|
|
188
|
+
|
|
189
|
+
function baseAttributes(ctx: TelemetryDevExtensionContext) {
|
|
190
|
+
return {
|
|
191
|
+
"gen_ai.conversation.id": sessionId(ctx),
|
|
192
|
+
"gen_ai.agent.name": agentName,
|
|
193
|
+
} satisfies Attrs;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function emit(
|
|
197
|
+
eventName: string,
|
|
198
|
+
ctx: TelemetryDevExtensionContext,
|
|
199
|
+
level: LogLevel,
|
|
200
|
+
message: string,
|
|
201
|
+
attributes: Attrs = {},
|
|
202
|
+
): void {
|
|
203
|
+
log(message, { level, eventName, attributes: { ...baseAttributes(ctx), ...attributes } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function spanAttributes(ctx: TelemetryDevExtensionContext): Record<string, string> {
|
|
207
|
+
const id = sessionId(ctx);
|
|
208
|
+
return id ? { "gen_ai.conversation.id": id } : {};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function endDanglingToolSpans(): void {
|
|
212
|
+
for (const span of toolSpans.values()) {
|
|
213
|
+
span.end({ error: failureError("incomplete", "tool execution did not complete") });
|
|
214
|
+
}
|
|
215
|
+
toolSpans.clear();
|
|
216
|
+
chatSpanByToolCall.clear();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function endAgentSpan(fields: Parameters<SpanHandle["end"]>[0]): void {
|
|
220
|
+
endDanglingToolSpans();
|
|
221
|
+
agentSpan?.end(fields);
|
|
222
|
+
agentSpan = undefined;
|
|
223
|
+
agentMessageKey = undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function on<E extends JsonValue>(
|
|
227
|
+
event: string,
|
|
228
|
+
handler: (event: E, ctx: TelemetryDevExtensionContext) => void | Promise<void>,
|
|
229
|
+
): void {
|
|
230
|
+
register<E>(event, (payload, ctx) => {
|
|
231
|
+
try {
|
|
232
|
+
return handler(payload, ctx);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
reportError(onError, error);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
register("before_agent_start", (event: { prompt?: JsonValue }) => {
|
|
241
|
+
try {
|
|
242
|
+
pendingPrompt = stringField(event, "prompt");
|
|
243
|
+
} catch (error) {
|
|
244
|
+
reportError(onError, error);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
on("agent_start", (_event, ctx) => {
|
|
249
|
+
if (agentSpan) {
|
|
250
|
+
if (pendingPrompt === undefined) {
|
|
251
|
+
// Continuations (auto-retry, compaction, queued continuations)
|
|
252
|
+
// re-enter the loop without a fresh before_agent_start prompt, and
|
|
253
|
+
// the host launches the willContinue agent_end notification without
|
|
254
|
+
// awaiting it, so this agent_start can arrive before that event.
|
|
255
|
+
// Keep the original prompt span open until the terminal agent_end.
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// A fresh prompt while a previous loop never emitted agent_end: close
|
|
259
|
+
// the dangling span instead of silently merging two prompts into it.
|
|
260
|
+
endAgentSpan({ finishReason: "incomplete" });
|
|
261
|
+
}
|
|
262
|
+
agentSpan = startSpan("invoke_agent", {
|
|
263
|
+
type: "agent",
|
|
264
|
+
agentName,
|
|
265
|
+
input: pendingPrompt,
|
|
266
|
+
attributes: spanAttributes(ctx),
|
|
267
|
+
});
|
|
268
|
+
pendingPrompt = undefined;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
on("agent_end", (event: { messages: JsonValue[]; willContinue?: boolean }, _ctx) => {
|
|
272
|
+
if (!agentSpan) return;
|
|
273
|
+
if (event.willContinue === true) {
|
|
274
|
+
// The host scheduled another loop for this prompt (auto-retry,
|
|
275
|
+
// compaction, or a queued continuation); this agent_end is not
|
|
276
|
+
// terminal, so keep the prompt span open.
|
|
277
|
+
void flush();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
281
|
+
let lastAssistant: JsonRecord | undefined;
|
|
282
|
+
for (const message of messages) {
|
|
283
|
+
const record = asRecord(message);
|
|
284
|
+
if (record?.role === "assistant") lastAssistant = record;
|
|
285
|
+
}
|
|
286
|
+
if (lastAssistant) {
|
|
287
|
+
const eventMessageKey = assistantMessageKey(lastAssistant);
|
|
288
|
+
if (eventMessageKey === undefined || eventMessageKey !== agentMessageKey) {
|
|
289
|
+
void flush();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const stopReason = stringField(lastAssistant, "stopReason");
|
|
294
|
+
const errorMessage = stringField(lastAssistant, "errorMessage");
|
|
295
|
+
endAgentSpan({
|
|
296
|
+
output: textContent(lastAssistant?.content),
|
|
297
|
+
finishReason: stopReason,
|
|
298
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : undefined,
|
|
299
|
+
});
|
|
300
|
+
void flush();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
on("message_end", (event: { message: JsonValue }, ctx) => {
|
|
304
|
+
const message = asRecord(event.message);
|
|
305
|
+
if (message?.role !== "assistant") return;
|
|
306
|
+
if (agentSpan) agentMessageKey = assistantMessageKey(message);
|
|
307
|
+
const model = stringField(message, "model");
|
|
308
|
+
const startTime = numberField(message, "timestamp");
|
|
309
|
+
const duration = numberField(message, "duration");
|
|
310
|
+
const errorMessage = stringField(message, "errorMessage");
|
|
311
|
+
const stopReason = stringField(message, "stopReason");
|
|
312
|
+
const span = startSpan(model ? `chat ${model}` : "chat", {
|
|
313
|
+
type: "generation",
|
|
314
|
+
parent: agentSpan,
|
|
315
|
+
startTime,
|
|
316
|
+
model,
|
|
317
|
+
provider: stringField(message, "provider"),
|
|
318
|
+
responseId: stringField(message, "responseId"),
|
|
319
|
+
usage: usageFields(message),
|
|
320
|
+
finishReason: stopReason,
|
|
321
|
+
timeToFirstChunkMs: numberField(message, "ttft"),
|
|
322
|
+
output: textContent(message.content),
|
|
323
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : undefined,
|
|
324
|
+
attributes: spanAttributes(ctx),
|
|
325
|
+
});
|
|
326
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
327
|
+
const record = asRecord(block);
|
|
328
|
+
const id = stringField(record, "id");
|
|
329
|
+
if (record?.type === "toolCall" && id !== undefined) {
|
|
330
|
+
chatSpanByToolCall.set(id, span);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
span.end({
|
|
334
|
+
endTime:
|
|
335
|
+
startTime !== undefined && duration !== undefined ? startTime + duration : undefined,
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
on(
|
|
340
|
+
"tool_execution_start",
|
|
341
|
+
(event: { toolCallId: string; toolName: string; args: JsonValue }, ctx) => {
|
|
342
|
+
const span = startSpan(`execute_tool ${event.toolName}`, {
|
|
343
|
+
type: "tool",
|
|
344
|
+
parent: chatSpanByToolCall.get(event.toolCallId) ?? agentSpan,
|
|
345
|
+
toolName: event.toolName,
|
|
346
|
+
toolCallId: event.toolCallId,
|
|
347
|
+
input: event.args,
|
|
348
|
+
attributes: spanAttributes(ctx),
|
|
349
|
+
});
|
|
350
|
+
chatSpanByToolCall.delete(event.toolCallId);
|
|
351
|
+
toolSpans.set(event.toolCallId, span);
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
on(
|
|
356
|
+
"tool_execution_end",
|
|
357
|
+
(
|
|
358
|
+
event: { toolCallId: string; toolName: string; result: JsonValue; isError: boolean },
|
|
359
|
+
_ctx,
|
|
360
|
+
) => {
|
|
361
|
+
const span = toolSpans.get(event.toolCallId);
|
|
362
|
+
if (!span) return;
|
|
363
|
+
toolSpans.delete(event.toolCallId);
|
|
364
|
+
const resultText = textContent(asRecord(event.result)?.content);
|
|
365
|
+
span.end({
|
|
366
|
+
output: event.result,
|
|
367
|
+
error: event.isError ? failureError("ToolExecutionError", resultText) : undefined,
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
on("turn_start", (event: { turnIndex: number }, ctx) => {
|
|
373
|
+
emit("turn_start", ctx, "debug", "Turn started", { "omp.turn.index": event.turnIndex });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
on("turn_end", (event: { turnIndex: number }, ctx) => {
|
|
377
|
+
emit("turn_end", ctx, "debug", "Turn completed", { "omp.turn.index": event.turnIndex });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
on("session_start", (_event, ctx) => {
|
|
381
|
+
const model = asRecord(ctx.model);
|
|
382
|
+
emit("session_start", ctx, "info", "Session started", {
|
|
383
|
+
"gen_ai.request.model": stringField(model, "id"),
|
|
384
|
+
"gen_ai.provider.name": stringField(model, "provider"),
|
|
385
|
+
"omp.cwd": ctx.cwd,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
on("session_switch", (_event, ctx) => {
|
|
390
|
+
emit("session_switch", ctx, "info", "Session switched");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
on("session_branch", (_event, ctx) => {
|
|
394
|
+
emit("session_branch", ctx, "info", "Session branched");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
on("session_compact", (_event, ctx) => {
|
|
398
|
+
emit("session_compact", ctx, "info", "Session compacted");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
on("auto_compaction_start", (event: { reason: string; action: string }, ctx) => {
|
|
402
|
+
emit("auto_compaction_start", ctx, "info", `Auto-compaction started (${event.reason})`, {
|
|
403
|
+
"omp.compaction.reason": event.reason,
|
|
404
|
+
"omp.compaction.action": event.action,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
on(
|
|
409
|
+
"auto_compaction_end",
|
|
410
|
+
(
|
|
411
|
+
event: {
|
|
412
|
+
action: string;
|
|
413
|
+
aborted: boolean;
|
|
414
|
+
willRetry: boolean;
|
|
415
|
+
errorMessage?: string;
|
|
416
|
+
skipped?: boolean;
|
|
417
|
+
},
|
|
418
|
+
ctx,
|
|
419
|
+
) => {
|
|
420
|
+
const record = asRecord(event);
|
|
421
|
+
const errorMessage = stringField(record, "errorMessage");
|
|
422
|
+
emit(
|
|
423
|
+
"auto_compaction_end",
|
|
424
|
+
ctx,
|
|
425
|
+
errorMessage ? "error" : "info",
|
|
426
|
+
errorMessage ? `Auto-compaction failed: ${errorMessage}` : "Auto-compaction completed",
|
|
427
|
+
{
|
|
428
|
+
"omp.compaction.action": event.action,
|
|
429
|
+
"omp.compaction.aborted": booleanField(record, "aborted"),
|
|
430
|
+
"omp.compaction.skipped": booleanField(record, "skipped"),
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
on(
|
|
437
|
+
"auto_retry_start",
|
|
438
|
+
(
|
|
439
|
+
event: { attempt: number; maxAttempts: number; delayMs: number; errorMessage: string },
|
|
440
|
+
ctx,
|
|
441
|
+
) => {
|
|
442
|
+
emit(
|
|
443
|
+
"auto_retry_start",
|
|
444
|
+
ctx,
|
|
445
|
+
"warn",
|
|
446
|
+
`Auto-retry attempt ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`,
|
|
447
|
+
{
|
|
448
|
+
"omp.retry.attempt": event.attempt,
|
|
449
|
+
"omp.retry.max_attempts": event.maxAttempts,
|
|
450
|
+
"omp.retry.delay_ms": event.delayMs,
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
on(
|
|
457
|
+
"auto_retry_end",
|
|
458
|
+
(event: { success: boolean; attempt: number; finalError?: string }, ctx) => {
|
|
459
|
+
const finalError = stringField(asRecord(event), "finalError");
|
|
460
|
+
emit(
|
|
461
|
+
"auto_retry_end",
|
|
462
|
+
ctx,
|
|
463
|
+
event.success ? "info" : "error",
|
|
464
|
+
event.success
|
|
465
|
+
? `Auto-retry succeeded after ${event.attempt} attempt(s)`
|
|
466
|
+
: `Auto-retry failed: ${finalError ?? "unknown error"}`,
|
|
467
|
+
{ "omp.retry.attempt": event.attempt },
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
register("session_shutdown", async (_event: JsonValue, ctx: TelemetryDevExtensionContext) => {
|
|
473
|
+
try {
|
|
474
|
+
if (agentSpan) endAgentSpan({ finishReason: "incomplete" });
|
|
475
|
+
emit("session_shutdown", ctx, "info", "Session shutdown");
|
|
476
|
+
await flush();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
reportError(onError, error);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
}
|
package/src/index.ts
ADDED
package/src/register.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { telemetryDevExtension } from "./extension.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ready-to-load omp extension entry configured from `TELEMETRY_DEV_*`
|
|
5
|
+
* environment variables. Referenced by this package's `omp.extensions`
|
|
6
|
+
* manifest so `@telemetry-dev/omp` works as an installed omp package.
|
|
7
|
+
*/
|
|
8
|
+
export default telemetryDevExtension();
|