@telemetry-dev/pi 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 +106 -0
- package/dist/extension-CZQ_PvAB.d.mts +55 -0
- package/dist/extension-DLbX-piL.mjs +274 -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 +77 -0
- package/src/config.ts +34 -0
- package/src/extension.ts +376 -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,106 @@
|
|
|
1
|
+
# @telemetry-dev/pi
|
|
2
|
+
|
|
3
|
+
Telemetry integration for the [pi coding agent](https://github.com/earendil-works/pi) and
|
|
4
|
+
[telemetry.dev](https://telemetry.dev). It records pi agent loops, model calls, tool executions, and
|
|
5
|
+
lifecycle events without changing pi's behavior.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Add the package to `~/.pi/agent/settings.json`:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"packages": ["npm:@telemetry-dev/pi"]
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Pi reads the package's `pi.extensions` manifest and loads `@telemetry-dev/pi/register`
|
|
18
|
+
automatically.
|
|
19
|
+
|
|
20
|
+
Alternatively, install `@telemetry-dev/pi` where pi can resolve it and create
|
|
21
|
+
`~/.pi/agent/extensions/telemetry-dev.ts`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { telemetryDevExtension } from "@telemetry-dev/pi";
|
|
25
|
+
export default telemetryDevExtension();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The integration targets `@earendil-works/pi-coding-agent` 0.82.1. The pi package is an optional peer
|
|
29
|
+
dependency because pi bundles its extension API and provides it to installed extensions.
|
|
30
|
+
|
|
31
|
+
## Environment
|
|
32
|
+
|
|
33
|
+
| Variable | Required | Default | Notes |
|
|
34
|
+
| --------------------------- | -------- | ------------------------------ | ----------------------------------------------------------------------- |
|
|
35
|
+
| `TELEMETRY_DEV_API_KEY` | yes | — | Ingest key (`td_live_…`). No key ⇒ the integration is a complete no-op. |
|
|
36
|
+
| `TELEMETRY_DEV_BASE_URL` | no | `https://ingest.telemetry.dev` | Trailing slashes are stripped. |
|
|
37
|
+
| `TELEMETRY_DEV_ENVIRONMENT` | no | `production` | Environment label on every trace/log. |
|
|
38
|
+
| `OTEL_SERVICE_NAME` | no | `pi` | OpenTelemetry service name. |
|
|
39
|
+
|
|
40
|
+
All four are also settable through SDK options when loading the extension directly:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { telemetryDevExtension } from "@telemetry-dev/pi";
|
|
44
|
+
|
|
45
|
+
export default telemetryDevExtension({
|
|
46
|
+
agentName: "pair-programmer",
|
|
47
|
+
captureInput: false,
|
|
48
|
+
captureOutput: true,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Initialization is one-shot; the first extension factory initialized in a process supplies the SDK
|
|
53
|
+
options.
|
|
54
|
+
|
|
55
|
+
## Trace shape
|
|
56
|
+
|
|
57
|
+
A typical prompt produces this hierarchy:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
invoke_agent
|
|
61
|
+
├── chat {model} (assistant message)
|
|
62
|
+
│ ├── execute_tool {toolName} (tool calls issued by that message)
|
|
63
|
+
│ └── execute_tool {toolName}
|
|
64
|
+
└── chat {model} (final assistant message)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- one `invoke_agent` span for the full agent loop, including the prompt and final assistant text;
|
|
68
|
+
- one nested `chat {model}` span per completed assistant message, with provider, requested and
|
|
69
|
+
response model, response id, finish reason, usage, and pi's estimated USD cost;
|
|
70
|
+
- one `execute_tool {toolName}` span per tool execution, nested under the `chat` span whose tool
|
|
71
|
+
call issued it (tool call ids are matched against the assistant message's tool-call blocks;
|
|
72
|
+
executions with no matching message fall back to the `invoke_agent` span), including the tool
|
|
73
|
+
call id, arguments, result, and error state;
|
|
74
|
+
- lifecycle logs for sessions, turns, compaction, and model changes.
|
|
75
|
+
|
|
76
|
+
Every span and log reads `ctx.sessionManager.getSessionId()` when its event fires and records it as
|
|
77
|
+
`gen_ai.conversation.id`. This keeps `/new`, `/resume`, `/fork`, and `/reload` events attached to the
|
|
78
|
+
new session rather than a cached id. Logs also carry `gen_ai.agent.name` and the raw pi event type as
|
|
79
|
+
`eventName`.
|
|
80
|
+
|
|
81
|
+
`agent_end` closes any unfinished tool spans and starts an asynchronous flush. `session_shutdown`
|
|
82
|
+
closes an unfinished agent loop and awaits a final flush before pi tears down or switches sessions.
|
|
83
|
+
It never shuts down the SDK during a pi session change.
|
|
84
|
+
|
|
85
|
+
## Content capture
|
|
86
|
+
|
|
87
|
+
Prompt input, assistant text output, tool arguments, and tool results follow the telemetry.dev SDK
|
|
88
|
+
`captureInput` and `captureOutput` settings. Both default to `true`. Use `mask` to redact values before
|
|
89
|
+
capture and `maxAttributeLength` to bound serialized attributes.
|
|
90
|
+
|
|
91
|
+
Thinking and tool-call content blocks are not copied into assistant output; only text blocks are
|
|
92
|
+
joined. Usage metadata still includes reasoning-token counts when pi reports them.
|
|
93
|
+
|
|
94
|
+
## Limitations
|
|
95
|
+
|
|
96
|
+
- Pi does not expose per-request TTFT or duration fields. Chat duration is measured from
|
|
97
|
+
`message_start` to `message_end` using local wall-clock time, and TTFT is not recorded.
|
|
98
|
+
- `tool_call` and `tool_result` are intentionally not intercepted. In pi, an uncaught `tool_call`
|
|
99
|
+
handler error blocks the user's tool, so this integration uses the fail-open
|
|
100
|
+
`tool_execution_start` / `tool_execution_end` events instead.
|
|
101
|
+
- An assistant message with `stopReason: "aborted"` is recorded with finish reason `aborted`, not as an
|
|
102
|
+
error. Only `stopReason: "error"` marks chat and agent spans as errors.
|
|
103
|
+
- `gen_ai.usage.cost` is pi's client-side USD estimate from its bundled model pricing, not a charge or
|
|
104
|
+
server-calculated invoice value.
|
|
105
|
+
- Initialization is one-shot. Loading the package more than once does not replace the first
|
|
106
|
+
configuration.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ClientOverrides, TelemetryOptions } from "@telemetry-dev/sdk";
|
|
2
|
+
|
|
3
|
+
//#region src/config.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* SDK options accepted by @telemetry-dev/pi. `registerGlobal` is excluded on
|
|
6
|
+
* purpose: pi 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 TelemetryDevPiOptions = Omit<TelemetryOptions, "registerGlobal">;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/extension.d.ts
|
|
13
|
+
/** Options for {@link telemetryDevExtension}. */
|
|
14
|
+
interface TelemetryDevExtensionOptions extends TelemetryDevPiOptions {
|
|
15
|
+
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"pi"`. */
|
|
16
|
+
agentName?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Structural slice of pi's `ExtensionContext` read by this integration.
|
|
20
|
+
*
|
|
21
|
+
* The host package (`@earendil-works/pi-coding-agent`) is an optional peer, so
|
|
22
|
+
* its types must not appear in this package's emitted declarations;
|
|
23
|
+
* assignability 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: unknown;
|
|
30
|
+
/** Read-only session manager exposing the session id. */
|
|
31
|
+
sessionManager: {
|
|
32
|
+
getSessionId(): string | undefined;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Structural stand-in for the host `ExtensionAPI` passed to extension
|
|
37
|
+
* factories. The `never` parameters make any host event-subscription surface
|
|
38
|
+
* assignable; this integration is not meant to be called through this type.
|
|
39
|
+
*/
|
|
40
|
+
interface TelemetryDevExtensionHost {
|
|
41
|
+
on(event: never, handler: never): void;
|
|
42
|
+
}
|
|
43
|
+
/** Structural stand-in for the host `ExtensionFactory` type. */
|
|
44
|
+
type TelemetryDevExtension = (pi: TelemetryDevExtensionHost) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Creates a pi extension factory that exports agent loops, model calls, tool
|
|
47
|
+
* executions, and lifecycle logs to telemetry.dev.
|
|
48
|
+
*
|
|
49
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
50
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
51
|
+
* tool execution. Logs join the same session via `gen_ai.conversation.id`.
|
|
52
|
+
*/
|
|
53
|
+
declare function telemetryDevExtension(options?: TelemetryDevExtensionOptions, overrides?: ClientOverrides): TelemetryDevExtension;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { telemetryDevExtension as a, TelemetryDevExtensionOptions as i, TelemetryDevExtensionContext as n, TelemetryDevPiOptions as o, TelemetryDevExtensionHost as r, TelemetryDevExtension as t };
|
|
@@ -0,0 +1,274 @@
|
|
|
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 ?? "pi",
|
|
11
|
+
registerGlobal: false
|
|
12
|
+
}, overrides);
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/extension.ts
|
|
16
|
+
function asRecord(cause) {
|
|
17
|
+
if (cause === null || cause instanceof Function || Object(cause) !== cause) return void 0;
|
|
18
|
+
return cause;
|
|
19
|
+
}
|
|
20
|
+
function stringField(record, key) {
|
|
21
|
+
const value = record?.[key];
|
|
22
|
+
return value?.constructor === String && value.length > 0 ? value : void 0;
|
|
23
|
+
}
|
|
24
|
+
function numberField(record, key) {
|
|
25
|
+
const value = record?.[key];
|
|
26
|
+
return value?.constructor === Number && Number.isFinite(value) ? value : void 0;
|
|
27
|
+
}
|
|
28
|
+
function booleanField(record, key) {
|
|
29
|
+
const value = record?.[key];
|
|
30
|
+
return value?.constructor === Boolean ? value : void 0;
|
|
31
|
+
}
|
|
32
|
+
function reportError(onError, cause) {
|
|
33
|
+
try {
|
|
34
|
+
onError?.call(void 0, cause);
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
function sessionId(ctx) {
|
|
38
|
+
const id = ctx.sessionManager.getSessionId();
|
|
39
|
+
return id?.constructor === String && id.length > 0 ? id : void 0;
|
|
40
|
+
}
|
|
41
|
+
/** Joins the text blocks of a pi message content array. */
|
|
42
|
+
function textContent(content) {
|
|
43
|
+
if (!Array.isArray(content)) return void 0;
|
|
44
|
+
const parts = [];
|
|
45
|
+
for (const block of content) {
|
|
46
|
+
const record = asRecord(block);
|
|
47
|
+
if (record?.type === "text" && record.text?.constructor === String) parts.push(record.text);
|
|
48
|
+
}
|
|
49
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
50
|
+
}
|
|
51
|
+
function usageFields(message) {
|
|
52
|
+
const usage = asRecord(message.usage);
|
|
53
|
+
if (!usage) return void 0;
|
|
54
|
+
return {
|
|
55
|
+
inputTokens: numberField(usage, "input"),
|
|
56
|
+
outputTokens: numberField(usage, "output"),
|
|
57
|
+
totalTokens: numberField(usage, "totalTokens"),
|
|
58
|
+
cacheReadInputTokens: numberField(usage, "cacheRead"),
|
|
59
|
+
cacheCreationInputTokens: numberField(usage, "cacheWrite"),
|
|
60
|
+
reasoningOutputTokens: numberField(usage, "reasoning")
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Named error so `error.type` reflects the failure class instead of "Error". */
|
|
64
|
+
function failureError(name, message) {
|
|
65
|
+
const error = new Error(message ?? name);
|
|
66
|
+
error.name = name;
|
|
67
|
+
return error;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Creates a pi extension factory that exports agent loops, model calls, tool
|
|
71
|
+
* executions, and lifecycle logs to telemetry.dev.
|
|
72
|
+
*
|
|
73
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
74
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
75
|
+
* tool execution. Logs join the same session via `gen_ai.conversation.id`.
|
|
76
|
+
*/
|
|
77
|
+
function telemetryDevExtension(options = {}, overrides) {
|
|
78
|
+
const { agentName = "pi", ...sdkOptions } = options;
|
|
79
|
+
const onError = sdkOptions.onError;
|
|
80
|
+
return (pi) => {
|
|
81
|
+
try {
|
|
82
|
+
ensureInit(sdkOptions, overrides);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
reportError(onError, error);
|
|
85
|
+
}
|
|
86
|
+
/** Calls through `pi` so the host's `on` keeps its `this` binding. */
|
|
87
|
+
function register(event, handler) {
|
|
88
|
+
pi.on(event, handler);
|
|
89
|
+
}
|
|
90
|
+
let agentSpan;
|
|
91
|
+
let assistantStartedAt;
|
|
92
|
+
let pendingPrompt;
|
|
93
|
+
/** Latest agent_end outcome, applied to the prompt span when the run settles. */
|
|
94
|
+
let lastRunResult;
|
|
95
|
+
/** Chat span that issued each pending tool call, so tool spans nest under it. */
|
|
96
|
+
const chatSpanByToolCall = /* @__PURE__ */ new Map();
|
|
97
|
+
const toolSpans = /* @__PURE__ */ new Map();
|
|
98
|
+
function baseAttributes(ctx) {
|
|
99
|
+
return {
|
|
100
|
+
"gen_ai.conversation.id": sessionId(ctx),
|
|
101
|
+
"gen_ai.agent.name": agentName
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function emit(eventName, ctx, level, message, attributes = {}) {
|
|
105
|
+
log(message, {
|
|
106
|
+
level,
|
|
107
|
+
eventName,
|
|
108
|
+
attributes: {
|
|
109
|
+
...baseAttributes(ctx),
|
|
110
|
+
...attributes
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function spanAttributes(ctx) {
|
|
115
|
+
const id = sessionId(ctx);
|
|
116
|
+
return id ? { "gen_ai.conversation.id": id } : {};
|
|
117
|
+
}
|
|
118
|
+
function endDanglingToolSpans() {
|
|
119
|
+
for (const span of toolSpans.values()) span.end({ error: failureError("incomplete", "tool execution did not complete") });
|
|
120
|
+
toolSpans.clear();
|
|
121
|
+
chatSpanByToolCall.clear();
|
|
122
|
+
}
|
|
123
|
+
function endAgentSpan(fields) {
|
|
124
|
+
endDanglingToolSpans();
|
|
125
|
+
agentSpan?.end(fields);
|
|
126
|
+
agentSpan = void 0;
|
|
127
|
+
}
|
|
128
|
+
function on(event, handler) {
|
|
129
|
+
register(event, (payload, ctx) => {
|
|
130
|
+
try {
|
|
131
|
+
handler(payload, ctx);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
reportError(onError, error);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
register("before_agent_start", (event) => {
|
|
138
|
+
try {
|
|
139
|
+
pendingPrompt = event.prompt?.constructor === String ? event.prompt : void 0;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
reportError(onError, error);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
on("agent_start", (_event, ctx) => {
|
|
145
|
+
if (agentSpan) return;
|
|
146
|
+
agentSpan = startSpan("invoke_agent", {
|
|
147
|
+
type: "agent",
|
|
148
|
+
agentName,
|
|
149
|
+
input: pendingPrompt,
|
|
150
|
+
attributes: spanAttributes(ctx)
|
|
151
|
+
});
|
|
152
|
+
pendingPrompt = void 0;
|
|
153
|
+
});
|
|
154
|
+
on("agent_end", (event, _ctx) => {
|
|
155
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
156
|
+
let lastAssistant;
|
|
157
|
+
for (const message of messages) {
|
|
158
|
+
const record = asRecord(message);
|
|
159
|
+
if (record?.role === "assistant") lastAssistant = record;
|
|
160
|
+
}
|
|
161
|
+
const stopReason = stringField(lastAssistant, "stopReason");
|
|
162
|
+
const errorMessage = stringField(lastAssistant, "errorMessage");
|
|
163
|
+
lastRunResult = {
|
|
164
|
+
output: textContent(lastAssistant?.content),
|
|
165
|
+
finishReason: stopReason,
|
|
166
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : void 0
|
|
167
|
+
};
|
|
168
|
+
flush();
|
|
169
|
+
});
|
|
170
|
+
on("agent_settled", (_event, _ctx) => {
|
|
171
|
+
endAgentSpan(lastRunResult ?? { finishReason: "incomplete" });
|
|
172
|
+
lastRunResult = void 0;
|
|
173
|
+
flush();
|
|
174
|
+
});
|
|
175
|
+
on("message_start", (event, _ctx) => {
|
|
176
|
+
if (asRecord(event.message)?.role === "assistant") assistantStartedAt = Date.now();
|
|
177
|
+
});
|
|
178
|
+
on("message_end", (event, ctx) => {
|
|
179
|
+
const message = asRecord(event.message);
|
|
180
|
+
if (message?.role !== "assistant") return;
|
|
181
|
+
const startTime = assistantStartedAt;
|
|
182
|
+
assistantStartedAt = void 0;
|
|
183
|
+
const endTime = Date.now();
|
|
184
|
+
const model = stringField(message, "model");
|
|
185
|
+
const responseModel = stringField(message, "responseModel") ?? model;
|
|
186
|
+
const errorMessage = stringField(message, "errorMessage");
|
|
187
|
+
const stopReason = stringField(message, "stopReason");
|
|
188
|
+
const span = startSpan(model ? `chat ${model}` : "chat", {
|
|
189
|
+
type: "generation",
|
|
190
|
+
parent: agentSpan,
|
|
191
|
+
startTime,
|
|
192
|
+
model,
|
|
193
|
+
provider: stringField(message, "provider"),
|
|
194
|
+
responseModel,
|
|
195
|
+
responseId: stringField(message, "responseId"),
|
|
196
|
+
usage: usageFields(message),
|
|
197
|
+
costUsd: numberField(asRecord(asRecord(message.usage)?.cost), "total"),
|
|
198
|
+
finishReason: stopReason,
|
|
199
|
+
output: textContent(message.content),
|
|
200
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : void 0,
|
|
201
|
+
attributes: spanAttributes(ctx)
|
|
202
|
+
});
|
|
203
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
204
|
+
const record = asRecord(block);
|
|
205
|
+
if (record?.type === "toolCall" && record.id?.constructor === String) chatSpanByToolCall.set(record.id, span);
|
|
206
|
+
}
|
|
207
|
+
span.end({ endTime });
|
|
208
|
+
});
|
|
209
|
+
on("tool_execution_start", (event, ctx) => {
|
|
210
|
+
const span = startSpan(`execute_tool ${event.toolName}`, {
|
|
211
|
+
type: "tool",
|
|
212
|
+
parent: chatSpanByToolCall.get(event.toolCallId) ?? agentSpan,
|
|
213
|
+
toolName: event.toolName,
|
|
214
|
+
toolCallId: event.toolCallId,
|
|
215
|
+
input: event.args,
|
|
216
|
+
attributes: spanAttributes(ctx)
|
|
217
|
+
});
|
|
218
|
+
chatSpanByToolCall.delete(event.toolCallId);
|
|
219
|
+
toolSpans.set(event.toolCallId, span);
|
|
220
|
+
});
|
|
221
|
+
on("tool_execution_end", (event, _ctx) => {
|
|
222
|
+
const span = toolSpans.get(event.toolCallId);
|
|
223
|
+
if (!span) return;
|
|
224
|
+
toolSpans.delete(event.toolCallId);
|
|
225
|
+
const resultText = textContent(asRecord(event.result)?.content);
|
|
226
|
+
span.end({
|
|
227
|
+
output: event.result,
|
|
228
|
+
error: event.isError ? failureError("ToolExecutionError", resultText) : void 0
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
on("turn_start", (event, ctx) => {
|
|
232
|
+
emit("turn_start", ctx, "debug", "Turn started", { "pi.turn.index": event.turnIndex });
|
|
233
|
+
});
|
|
234
|
+
on("turn_end", (event, ctx) => {
|
|
235
|
+
emit("turn_end", ctx, "debug", "Turn completed", { "pi.turn.index": event.turnIndex });
|
|
236
|
+
});
|
|
237
|
+
on("session_start", (event, ctx) => {
|
|
238
|
+
const model = asRecord(ctx.model);
|
|
239
|
+
emit("session_start", ctx, "info", "Session started", {
|
|
240
|
+
"gen_ai.request.model": stringField(model, "id"),
|
|
241
|
+
"gen_ai.provider.name": stringField(model, "provider"),
|
|
242
|
+
"pi.cwd": ctx.cwd,
|
|
243
|
+
"pi.session.reason": event.reason
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
on("session_compact", (event, ctx) => {
|
|
247
|
+
emit("session_compact", ctx, "info", "Session compacted", {
|
|
248
|
+
"pi.compaction.from_extension": booleanField(asRecord(event), "fromExtension"),
|
|
249
|
+
"pi.compaction.reason": event.reason
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
on("model_select", (event, ctx) => {
|
|
253
|
+
emit("model_select", ctx, "info", "Model changed", {
|
|
254
|
+
"pi.model.from": stringField(asRecord(event.previousModel), "id"),
|
|
255
|
+
"pi.model.source": event.source,
|
|
256
|
+
"pi.model.to": stringField(asRecord(event.model), "id")
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
register("session_shutdown", async (event, ctx) => {
|
|
260
|
+
try {
|
|
261
|
+
if (agentSpan || toolSpans.size > 0) {
|
|
262
|
+
endAgentSpan(lastRunResult ?? { finishReason: "incomplete" });
|
|
263
|
+
lastRunResult = void 0;
|
|
264
|
+
}
|
|
265
|
+
emit("session_shutdown", ctx, "info", "Session shutdown", { "pi.session.reason": event.reason });
|
|
266
|
+
await flush();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
reportError(onError, error);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
export { 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 TelemetryDevPiOptions, r as TelemetryDevExtensionHost, t as TelemetryDevExtension } from "./extension-CZQ_PvAB.mjs";
|
|
2
|
+
export { type TelemetryDevExtension, type TelemetryDevExtensionContext, type TelemetryDevExtensionHost, type TelemetryDevExtensionOptions, type TelemetryDevPiOptions, telemetryDevExtension };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { t as TelemetryDevExtension } from "./extension-CZQ_PvAB.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/register.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Ready-to-load pi extension entry configured from `TELEMETRY_DEV_*`
|
|
6
|
+
* environment variables. Referenced by this package's `pi.extensions`
|
|
7
|
+
* manifest so `@telemetry-dev/pi` works as an installed pi 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-DLbX-piL.mjs";
|
|
2
|
+
//#region src/register.ts
|
|
3
|
+
/**
|
|
4
|
+
* Ready-to-load pi extension entry configured from `TELEMETRY_DEV_*`
|
|
5
|
+
* environment variables. Referenced by this package's `pi.extensions`
|
|
6
|
+
* manifest so `@telemetry-dev/pi` works as an installed pi package.
|
|
7
|
+
*/
|
|
8
|
+
var register_default = telemetryDevExtension();
|
|
9
|
+
//#endregion
|
|
10
|
+
export { register_default as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telemetry-dev/pi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi coding-agent telemetry integration for telemetry.dev.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"coding-agent",
|
|
8
|
+
"genai",
|
|
9
|
+
"llm",
|
|
10
|
+
"observability",
|
|
11
|
+
"opentelemetry",
|
|
12
|
+
"pi",
|
|
13
|
+
"telemetry",
|
|
14
|
+
"tracing"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://telemetry.dev",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/telemetry-dev/telemetry.dev.git",
|
|
21
|
+
"directory": "packages/pi"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"import": "./dist/index.mjs",
|
|
32
|
+
"default": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./register": {
|
|
35
|
+
"types": "./dist/register.d.mts",
|
|
36
|
+
"import": "./dist/register.mjs",
|
|
37
|
+
"default": "./dist/register.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@opentelemetry/api": "^1.9.1",
|
|
46
|
+
"@telemetry-dev/sdk": "^0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
50
|
+
"@opentelemetry/sdk-logs": "^0.218.0",
|
|
51
|
+
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
52
|
+
"@types/node": "^25.5.0",
|
|
53
|
+
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
|
54
|
+
"typescript": "^6.0.2",
|
|
55
|
+
"vite-plus": "0.1.20",
|
|
56
|
+
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.20"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": ">=0.80.4"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@earendil-works/pi-coding-agent": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"pi": {
|
|
67
|
+
"extensions": [
|
|
68
|
+
"./dist/register.mjs"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "pnpm exec vp pack",
|
|
73
|
+
"dev": "pnpm exec vp pack --watch",
|
|
74
|
+
"test": "vp test",
|
|
75
|
+
"check": "vp check"
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { init, shutdown, type ClientOverrides, type TelemetryOptions } from "@telemetry-dev/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SDK options accepted by @telemetry-dev/pi. `registerGlobal` is excluded on
|
|
5
|
+
* purpose: pi 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 TelemetryDevPiOptions = 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(options: TelemetryDevPiOptions = {}, overrides?: ClientOverrides): void {
|
|
17
|
+
if (initialized) return;
|
|
18
|
+
initialized = true;
|
|
19
|
+
init(
|
|
20
|
+
{
|
|
21
|
+
...options,
|
|
22
|
+
serviceName: options.serviceName ?? process.env.OTEL_SERVICE_NAME ?? "pi",
|
|
23
|
+
// Enforce the guarantee above at runtime for untyped (plain JS) callers.
|
|
24
|
+
registerGlobal: false,
|
|
25
|
+
},
|
|
26
|
+
overrides,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Test seam for unit tests that need a fresh SDK singleton. */
|
|
31
|
+
export async function resetForTesting(): Promise<void> {
|
|
32
|
+
initialized = false;
|
|
33
|
+
await shutdown();
|
|
34
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
flush,
|
|
3
|
+
log,
|
|
4
|
+
startSpan,
|
|
5
|
+
type LogLevel,
|
|
6
|
+
type LogOptions,
|
|
7
|
+
type SpanHandle,
|
|
8
|
+
type TokenUsage,
|
|
9
|
+
} from "@telemetry-dev/sdk";
|
|
10
|
+
|
|
11
|
+
import { ensureInit, type ClientOverrides, type TelemetryDevPiOptions } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
/** Options for {@link telemetryDevExtension}. */
|
|
14
|
+
export interface TelemetryDevExtensionOptions extends TelemetryDevPiOptions {
|
|
15
|
+
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"pi"`. */
|
|
16
|
+
agentName?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Structural slice of pi's `ExtensionContext` read by this integration.
|
|
21
|
+
*
|
|
22
|
+
* The host package (`@earendil-works/pi-coding-agent`) is an optional peer, so
|
|
23
|
+
* its types must not appear in this package's emitted declarations;
|
|
24
|
+
* assignability to the real host types is asserted in this package's tests.
|
|
25
|
+
*/
|
|
26
|
+
export interface TelemetryDevExtensionContext {
|
|
27
|
+
/** Current working directory. */
|
|
28
|
+
cwd: string;
|
|
29
|
+
/** Current model descriptor, when one is selected. */
|
|
30
|
+
model: unknown;
|
|
31
|
+
/** Read-only session manager exposing the session id. */
|
|
32
|
+
sessionManager: { getSessionId(): string | undefined };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Structural stand-in for the host `ExtensionAPI` passed to extension
|
|
37
|
+
* factories. The `never` parameters make any host event-subscription surface
|
|
38
|
+
* assignable; this integration is not meant to be called through this type.
|
|
39
|
+
*/
|
|
40
|
+
export interface TelemetryDevExtensionHost {
|
|
41
|
+
on(event: never, handler: never): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Structural stand-in for the host `ExtensionFactory` type. */
|
|
45
|
+
export type TelemetryDevExtension = (pi: TelemetryDevExtensionHost) => void;
|
|
46
|
+
|
|
47
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
48
|
+
type JsonRecord = { [key: string]: JsonValue };
|
|
49
|
+
type Attrs = NonNullable<LogOptions["attributes"]>;
|
|
50
|
+
|
|
51
|
+
function asRecord(cause: unknown): JsonRecord | undefined {
|
|
52
|
+
if (cause === null || cause instanceof Function || Object(cause) !== cause) return undefined;
|
|
53
|
+
return cause as JsonRecord;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringField(record: JsonRecord | undefined, key: string): string | undefined {
|
|
57
|
+
const value = record?.[key];
|
|
58
|
+
return value?.constructor === String && value.length > 0 ? value : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function numberField(record: JsonRecord | undefined, key: string): number | undefined {
|
|
62
|
+
const value = record?.[key];
|
|
63
|
+
return value?.constructor === Number && Number.isFinite(value) ? value : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function booleanField(record: JsonRecord | undefined, key: string): boolean | undefined {
|
|
67
|
+
const value = record?.[key];
|
|
68
|
+
return value?.constructor === Boolean ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reportError(onError: Function | undefined, cause: unknown): void {
|
|
72
|
+
try {
|
|
73
|
+
onError?.call(undefined, cause);
|
|
74
|
+
} catch {
|
|
75
|
+
// telemetry must never fail a pi session.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sessionId(ctx: TelemetryDevExtensionContext): string | undefined {
|
|
80
|
+
const id = ctx.sessionManager.getSessionId();
|
|
81
|
+
return id?.constructor === String && id.length > 0 ? id : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Joins the text blocks of a pi message content array. */
|
|
85
|
+
function textContent(content: JsonValue | undefined): string | undefined {
|
|
86
|
+
if (!Array.isArray(content)) return undefined;
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const block of content) {
|
|
89
|
+
const record = asRecord(block);
|
|
90
|
+
if (record?.type === "text" && record.text?.constructor === String) parts.push(record.text);
|
|
91
|
+
}
|
|
92
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function usageFields(message: JsonRecord): TokenUsage | undefined {
|
|
96
|
+
const usage = asRecord(message.usage);
|
|
97
|
+
if (!usage) return undefined;
|
|
98
|
+
return {
|
|
99
|
+
inputTokens: numberField(usage, "input"),
|
|
100
|
+
outputTokens: numberField(usage, "output"),
|
|
101
|
+
totalTokens: numberField(usage, "totalTokens"),
|
|
102
|
+
cacheReadInputTokens: numberField(usage, "cacheRead"),
|
|
103
|
+
cacheCreationInputTokens: numberField(usage, "cacheWrite"),
|
|
104
|
+
reasoningOutputTokens: numberField(usage, "reasoning"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Named error so `error.type` reflects the failure class instead of "Error". */
|
|
109
|
+
function failureError(name: string, message: string | undefined): Error {
|
|
110
|
+
const error = new Error(message ?? name);
|
|
111
|
+
error.name = name;
|
|
112
|
+
return error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a pi extension factory that exports agent loops, model calls, tool
|
|
117
|
+
* executions, and lifecycle logs to telemetry.dev.
|
|
118
|
+
*
|
|
119
|
+
* Trace shape per user prompt: one `invoke_agent` span, with one `chat {model}`
|
|
120
|
+
* child span per assistant message and one `execute_tool {tool}` child span per
|
|
121
|
+
* tool execution. Logs join the same session via `gen_ai.conversation.id`.
|
|
122
|
+
*/
|
|
123
|
+
export function telemetryDevExtension(
|
|
124
|
+
options: TelemetryDevExtensionOptions = {},
|
|
125
|
+
overrides?: ClientOverrides,
|
|
126
|
+
): TelemetryDevExtension {
|
|
127
|
+
const { agentName = "pi", ...sdkOptions } = options;
|
|
128
|
+
const onError = sdkOptions.onError;
|
|
129
|
+
|
|
130
|
+
return (pi) => {
|
|
131
|
+
try {
|
|
132
|
+
ensureInit(sdkOptions, overrides);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
reportError(onError, error);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Calls through `pi` so the host's `on` keeps its `this` binding. */
|
|
138
|
+
function register<Event>(
|
|
139
|
+
event: string,
|
|
140
|
+
handler: (event: Event, ctx: TelemetryDevExtensionContext) => void | Promise<void>,
|
|
141
|
+
): void {
|
|
142
|
+
pi.on(event as never, handler as never);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let agentSpan: SpanHandle | undefined;
|
|
146
|
+
let assistantStartedAt: number | undefined;
|
|
147
|
+
let pendingPrompt: string | undefined;
|
|
148
|
+
/** Latest agent_end outcome, applied to the prompt span when the run settles. */
|
|
149
|
+
let lastRunResult: Parameters<SpanHandle["end"]>[0];
|
|
150
|
+
/** Chat span that issued each pending tool call, so tool spans nest under it. */
|
|
151
|
+
const chatSpanByToolCall = new Map<string, SpanHandle>();
|
|
152
|
+
const toolSpans = new Map<string, SpanHandle>();
|
|
153
|
+
|
|
154
|
+
function baseAttributes(ctx: TelemetryDevExtensionContext) {
|
|
155
|
+
return {
|
|
156
|
+
"gen_ai.conversation.id": sessionId(ctx),
|
|
157
|
+
"gen_ai.agent.name": agentName,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function emit(
|
|
162
|
+
eventName: string,
|
|
163
|
+
ctx: TelemetryDevExtensionContext,
|
|
164
|
+
level: LogLevel,
|
|
165
|
+
message: string,
|
|
166
|
+
attributes: Attrs = {},
|
|
167
|
+
): void {
|
|
168
|
+
log(message, { level, eventName, attributes: { ...baseAttributes(ctx), ...attributes } });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function spanAttributes(ctx: TelemetryDevExtensionContext): Record<string, string> {
|
|
172
|
+
const id = sessionId(ctx);
|
|
173
|
+
return id ? { "gen_ai.conversation.id": id } : {};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function endDanglingToolSpans(): void {
|
|
177
|
+
for (const span of toolSpans.values()) {
|
|
178
|
+
span.end({ error: failureError("incomplete", "tool execution did not complete") });
|
|
179
|
+
}
|
|
180
|
+
toolSpans.clear();
|
|
181
|
+
chatSpanByToolCall.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function endAgentSpan(fields: Parameters<SpanHandle["end"]>[0]): void {
|
|
185
|
+
endDanglingToolSpans();
|
|
186
|
+
agentSpan?.end(fields);
|
|
187
|
+
agentSpan = undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function on<Event>(
|
|
191
|
+
event: string,
|
|
192
|
+
handler: (event: Event, ctx: TelemetryDevExtensionContext) => void,
|
|
193
|
+
): void {
|
|
194
|
+
register(event, (payload: Event, ctx) => {
|
|
195
|
+
try {
|
|
196
|
+
handler(payload, ctx);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
reportError(onError, error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
register("before_agent_start", (event: { prompt?: JsonValue }) => {
|
|
204
|
+
try {
|
|
205
|
+
pendingPrompt = event.prompt?.constructor === String ? event.prompt : undefined;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
reportError(onError, error);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
on("agent_start", (_event, ctx) => {
|
|
212
|
+
// Automatic retries, compaction, and queued continuations re-enter the
|
|
213
|
+
// agent loop before the run settles; keep the original prompt span open.
|
|
214
|
+
if (agentSpan) return;
|
|
215
|
+
agentSpan = startSpan("invoke_agent", {
|
|
216
|
+
type: "agent",
|
|
217
|
+
agentName,
|
|
218
|
+
input: pendingPrompt,
|
|
219
|
+
attributes: spanAttributes(ctx),
|
|
220
|
+
});
|
|
221
|
+
pendingPrompt = undefined;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
on("agent_end", (event: { messages: JsonValue[] }, _ctx) => {
|
|
225
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
226
|
+
let lastAssistant: JsonRecord | undefined;
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
const record = asRecord(message);
|
|
229
|
+
if (record?.role === "assistant") lastAssistant = record;
|
|
230
|
+
}
|
|
231
|
+
const stopReason = stringField(lastAssistant, "stopReason");
|
|
232
|
+
const errorMessage = stringField(lastAssistant, "errorMessage");
|
|
233
|
+
// agent_end only closes one low-level run; pi may remove a retryable
|
|
234
|
+
// failure and start another run before the prompt settles. Record the
|
|
235
|
+
// outcome and close the span at agent_settled.
|
|
236
|
+
lastRunResult = {
|
|
237
|
+
output: textContent(lastAssistant?.content),
|
|
238
|
+
finishReason: stopReason,
|
|
239
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : undefined,
|
|
240
|
+
};
|
|
241
|
+
void flush();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
on("agent_settled", (_event, _ctx) => {
|
|
245
|
+
endAgentSpan(lastRunResult ?? { finishReason: "incomplete" });
|
|
246
|
+
lastRunResult = undefined;
|
|
247
|
+
void flush();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
on("message_start", (event: { message: JsonValue }, _ctx) => {
|
|
251
|
+
const message = asRecord(event.message);
|
|
252
|
+
if (message?.role === "assistant") assistantStartedAt = Date.now();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
on("message_end", (event: { message: JsonValue }, ctx) => {
|
|
256
|
+
const message = asRecord(event.message);
|
|
257
|
+
if (message?.role !== "assistant") return;
|
|
258
|
+
const startTime = assistantStartedAt;
|
|
259
|
+
assistantStartedAt = undefined;
|
|
260
|
+
const endTime = Date.now();
|
|
261
|
+
const model = stringField(message, "model");
|
|
262
|
+
const responseModel = stringField(message, "responseModel") ?? model;
|
|
263
|
+
const errorMessage = stringField(message, "errorMessage");
|
|
264
|
+
const stopReason = stringField(message, "stopReason");
|
|
265
|
+
const span = startSpan(model ? `chat ${model}` : "chat", {
|
|
266
|
+
type: "generation",
|
|
267
|
+
parent: agentSpan,
|
|
268
|
+
startTime,
|
|
269
|
+
model,
|
|
270
|
+
provider: stringField(message, "provider"),
|
|
271
|
+
responseModel,
|
|
272
|
+
responseId: stringField(message, "responseId"),
|
|
273
|
+
usage: usageFields(message),
|
|
274
|
+
costUsd: numberField(asRecord(asRecord(message.usage)?.cost), "total"),
|
|
275
|
+
finishReason: stopReason,
|
|
276
|
+
output: textContent(message.content),
|
|
277
|
+
error: stopReason === "error" ? failureError(stopReason, errorMessage) : undefined,
|
|
278
|
+
attributes: spanAttributes(ctx),
|
|
279
|
+
});
|
|
280
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
281
|
+
const record = asRecord(block);
|
|
282
|
+
if (record?.type === "toolCall" && record.id?.constructor === String) {
|
|
283
|
+
chatSpanByToolCall.set(record.id, span);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
span.end({ endTime });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
on(
|
|
290
|
+
"tool_execution_start",
|
|
291
|
+
(event: { toolCallId: string; toolName: string; args: JsonValue }, ctx) => {
|
|
292
|
+
const span = startSpan(`execute_tool ${event.toolName}`, {
|
|
293
|
+
type: "tool",
|
|
294
|
+
parent: chatSpanByToolCall.get(event.toolCallId) ?? agentSpan,
|
|
295
|
+
toolName: event.toolName,
|
|
296
|
+
toolCallId: event.toolCallId,
|
|
297
|
+
input: event.args,
|
|
298
|
+
attributes: spanAttributes(ctx),
|
|
299
|
+
});
|
|
300
|
+
chatSpanByToolCall.delete(event.toolCallId);
|
|
301
|
+
toolSpans.set(event.toolCallId, span);
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
on(
|
|
306
|
+
"tool_execution_end",
|
|
307
|
+
(
|
|
308
|
+
event: { toolCallId: string; toolName: string; result: JsonValue; isError: boolean },
|
|
309
|
+
_ctx,
|
|
310
|
+
) => {
|
|
311
|
+
const span = toolSpans.get(event.toolCallId);
|
|
312
|
+
if (!span) return;
|
|
313
|
+
toolSpans.delete(event.toolCallId);
|
|
314
|
+
const resultText = textContent(asRecord(event.result)?.content);
|
|
315
|
+
span.end({
|
|
316
|
+
output: event.result,
|
|
317
|
+
error: event.isError ? failureError("ToolExecutionError", resultText) : undefined,
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
on("turn_start", (event: { turnIndex: number }, ctx) => {
|
|
323
|
+
emit("turn_start", ctx, "debug", "Turn started", { "pi.turn.index": event.turnIndex });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
on("turn_end", (event: { turnIndex: number }, ctx) => {
|
|
327
|
+
emit("turn_end", ctx, "debug", "Turn completed", { "pi.turn.index": event.turnIndex });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
on("session_start", (event: { reason: string; previousSessionFile?: string }, ctx) => {
|
|
331
|
+
const model = asRecord(ctx.model);
|
|
332
|
+
emit("session_start", ctx, "info", "Session started", {
|
|
333
|
+
"gen_ai.request.model": stringField(model, "id"),
|
|
334
|
+
"gen_ai.provider.name": stringField(model, "provider"),
|
|
335
|
+
"pi.cwd": ctx.cwd,
|
|
336
|
+
"pi.session.reason": event.reason,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
on("session_compact", (event: { reason: string; fromExtension: boolean }, ctx) => {
|
|
341
|
+
emit("session_compact", ctx, "info", "Session compacted", {
|
|
342
|
+
"pi.compaction.from_extension": booleanField(asRecord(event), "fromExtension"),
|
|
343
|
+
"pi.compaction.reason": event.reason,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
on(
|
|
348
|
+
"model_select",
|
|
349
|
+
(event: { model: JsonValue; previousModel: JsonValue; source: string }, ctx) => {
|
|
350
|
+
emit("model_select", ctx, "info", "Model changed", {
|
|
351
|
+
"pi.model.from": stringField(asRecord(event.previousModel), "id"),
|
|
352
|
+
"pi.model.source": event.source,
|
|
353
|
+
"pi.model.to": stringField(asRecord(event.model), "id"),
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
register(
|
|
359
|
+
"session_shutdown",
|
|
360
|
+
async (event: { reason?: string }, ctx: TelemetryDevExtensionContext) => {
|
|
361
|
+
try {
|
|
362
|
+
if (agentSpan || toolSpans.size > 0) {
|
|
363
|
+
endAgentSpan(lastRunResult ?? { finishReason: "incomplete" });
|
|
364
|
+
lastRunResult = undefined;
|
|
365
|
+
}
|
|
366
|
+
emit("session_shutdown", ctx, "info", "Session shutdown", {
|
|
367
|
+
"pi.session.reason": event.reason,
|
|
368
|
+
});
|
|
369
|
+
await flush();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
reportError(onError, error);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
};
|
|
376
|
+
}
|
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 pi extension entry configured from `TELEMETRY_DEV_*`
|
|
5
|
+
* environment variables. Referenced by this package's `pi.extensions`
|
|
6
|
+
* manifest so `@telemetry-dev/pi` works as an installed pi package.
|
|
7
|
+
*/
|
|
8
|
+
export default telemetryDevExtension();
|