@braintrust/pi-extension 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Braintrust
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,126 @@
1
+ # @braintrust/pi-extension
2
+
3
+ Braintrust extension for [pi](https://github.com/mariozechner/pi-coding-agent).
4
+
5
+ Today this extension automatically traces pi sessions, turns, model calls, and tool executions to Braintrust.
6
+
7
+ ## What gets traced
8
+
9
+ - **Session spans**: one root span per pi session that actually produces at least one turn
10
+ - **Turn spans**: one span per user prompt / agent run
11
+ - **LLM spans**: one span per model response inside a turn
12
+ - **Tool spans**: one span per tool execution
13
+
14
+ Trace shape:
15
+
16
+ ```text
17
+ Session (task)
18
+ ├── Turn 1 (task)
19
+ │ ├── anthropic/claude-sonnet-4 (llm)
20
+ │ │ ├── read: package.json (tool)
21
+ │ │ └── bash: pnpm test (tool)
22
+ │ └── anthropic/claude-sonnet-4 (llm)
23
+ └── Turn 2 (task)
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ### From npm
29
+
30
+ ```bash
31
+ pi install npm:@braintrust/pi-extension
32
+ ```
33
+
34
+ ### From this repo
35
+
36
+ ```bash
37
+ pi install .
38
+ ```
39
+
40
+ Or load it just for one run:
41
+
42
+ ```bash
43
+ pi -e .
44
+ ```
45
+
46
+ ## Compatibility
47
+
48
+ This package supports the **last three stable pi versions**.
49
+
50
+ Our GitHub Actions compatibility job automatically resolves and tests the latest patch release from each of the last three stable pi minor versions, so new pi releases are picked up without manually updating the matrix.
51
+
52
+ ## Quick start
53
+
54
+ Tracing is disabled by default.
55
+
56
+ Set these environment variables:
57
+
58
+ ```bash
59
+ export TRACE_TO_BRAINTRUST=true
60
+ export BRAINTRUST_API_KEY=sk-...
61
+ export BRAINTRUST_PROJECT=pi
62
+ ```
63
+
64
+ Then start pi normally.
65
+
66
+ In interactive mode, the footer shows a `Braintrust` status indicator while tracing is active, and a widget below the editor shows a shortened clickable trace link when available.
67
+
68
+ ## Configuration
69
+
70
+ You can configure the extension with environment variables or JSON config files.
71
+
72
+ Config precedence is:
73
+
74
+ 1. defaults
75
+ 2. `~/.pi/agent/braintrust.json`
76
+ 3. `.pi/braintrust.json`
77
+ 4. environment variables
78
+
79
+ ### Config file locations
80
+
81
+ - Global: `~/.pi/agent/braintrust.json`
82
+ - Project: `.pi/braintrust.json`
83
+
84
+ Example:
85
+
86
+ ```json
87
+ {
88
+ "trace_to_braintrust": true,
89
+ "project": "pi",
90
+ "debug": true,
91
+ "additional_metadata": {
92
+ "team": "platform"
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## Supported settings
98
+
99
+ | Config key | Env var | Default |
100
+ |---|---|---|
101
+ | `trace_to_braintrust` | `TRACE_TO_BRAINTRUST` | `false` |
102
+ | `api_key` | `BRAINTRUST_API_KEY` | unset |
103
+ | `api_url` | `BRAINTRUST_API_URL` | `https://api.braintrust.dev` |
104
+ | `app_url` | `BRAINTRUST_APP_URL` | `https://www.braintrust.dev` |
105
+ | `org_name` | `BRAINTRUST_ORG_NAME` | unset |
106
+ | `project` | `BRAINTRUST_PROJECT` | `pi` |
107
+ | `debug` | `BRAINTRUST_DEBUG` | `false` |
108
+ | `additional_metadata` | `BRAINTRUST_ADDITIONAL_METADATA` | `{}` |
109
+ | `log_file` | `BRAINTRUST_LOG_FILE` | unset |
110
+ | `state_dir` | `BRAINTRUST_STATE_DIR` | `~/.pi/agent/state/braintrust-pi-extension` |
111
+ | `parent_span_id` | `PI_PARENT_SPAN_ID` | unset |
112
+ | `root_span_id` | `PI_ROOT_SPAN_ID` | unset |
113
+
114
+ ## Notes
115
+
116
+ - Project config overrides global config.
117
+ - Environment variables override both config files.
118
+ - Session bookkeeping is stored in `~/.pi/agent/state/braintrust-pi-extension/` by default.
119
+ - Span delivery uses the Braintrust JavaScript SDK's built-in async/background flushing.
120
+ - If Braintrust is unavailable, pi should continue working normally.
121
+ - If `PI_PARENT_SPAN_ID` is set, the pi session span is attached under an existing Braintrust trace.
122
+ - `PI_ROOT_SPAN_ID` can be used when the parent span is not the trace root.
123
+
124
+ ## Contributing
125
+
126
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup, validation, and repository conventions.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@braintrust/pi-extension",
3
+ "version": "0.1.0",
4
+ "description": "Braintrust extension for pi. Includes automatic tracing for pi sessions, turns, LLM calls, and tool executions to Braintrust.",
5
+ "keywords": [
6
+ "braintrust",
7
+ "llm",
8
+ "observability",
9
+ "pi",
10
+ "pi-package",
11
+ "tracing"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/braintrustdata/braintrust-pi-extension"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "type": "module",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "braintrust": "^3.7.0",
28
+ "valibot": "^1.3.1"
29
+ },
30
+ "devDependencies": {
31
+ "@mariozechner/pi-ai": "^0.64.0",
32
+ "@mariozechner/pi-coding-agent": "^0.64.0",
33
+ "@types/node": "^25.5.0",
34
+ "tsx": "^4.21.0",
35
+ "typescript": "^6.0.2",
36
+ "vite-plus": "^0.1.14",
37
+ "vitest": "^4.1.2"
38
+ },
39
+ "peerDependencies": {
40
+ "@mariozechner/pi-coding-agent": "*"
41
+ },
42
+ "devEngines": {
43
+ "packageManager": {
44
+ "name": "pnpm",
45
+ "version": ">=10.33.0",
46
+ "onFail": "error"
47
+ }
48
+ },
49
+ "engines": {
50
+ "npm": "please-use-pnpm",
51
+ "pnpm": ">=10.33.0",
52
+ "yarn": "please-use-pnpm"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "./src/index.ts"
57
+ ]
58
+ },
59
+ "scripts": {
60
+ "preinstall": "node -e \"const userAgent = process.env.npm_config_user_agent || ''; if (process.env.INIT_CWD === process.cwd() && !userAgent.includes('pnpm/')) { console.error('Use pnpm in this repo.'); process.exit(1); }\"",
61
+ "check": "vp check",
62
+ "fmt": "vp fmt",
63
+ "lint": "vp lint",
64
+ "pack": "vp pack",
65
+ "test": "vitest run",
66
+ "test:integration": "vitest run src/index.integration.test.ts",
67
+ "test:watch": "vitest",
68
+ "typecheck": "vp check",
69
+ "smoke": "tsx -e \"import('./src/index.ts')\""
70
+ }
71
+ }
package/src/client.ts ADDED
@@ -0,0 +1,244 @@
1
+ import {
2
+ ERR_PERMALINK,
3
+ NOOP_SPAN_PERMALINK,
4
+ initLogger,
5
+ type Logger as BraintrustSdkLogger,
6
+ type Span as BraintrustSdkSpan,
7
+ } from "braintrust";
8
+ import type { Logger, TraceConfig } from "./types.ts";
9
+ import { toUnixSeconds } from "./utils.ts";
10
+
11
+ export type BraintrustSpanHandle = BraintrustSdkSpan;
12
+
13
+ export interface StartTraceSpanArgs {
14
+ spanId: string;
15
+ rootSpanId: string;
16
+ parentSpanId?: string;
17
+ name: string;
18
+ type: "task" | "llm" | "tool";
19
+ startedAt: number;
20
+ input?: unknown;
21
+ output?: unknown;
22
+ error?: string;
23
+ metadata?: Record<string, unknown>;
24
+ metrics?: Record<string, number | undefined>;
25
+ }
26
+
27
+ export interface UpdateTraceSpanArgs {
28
+ id: string;
29
+ spanId?: string;
30
+ rootSpanId?: string;
31
+ input?: unknown;
32
+ output?: unknown;
33
+ error?: string;
34
+ metadata?: Record<string, unknown>;
35
+ metrics?: Record<string, number | undefined>;
36
+ }
37
+
38
+ function compactRecord<T extends Record<string, unknown>>(value: T): Partial<T> {
39
+ return Object.fromEntries(
40
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
41
+ ) as Partial<T>;
42
+ }
43
+
44
+ function isUsablePermalink(url: string | undefined): url is string {
45
+ return Boolean(url && url !== NOOP_SPAN_PERMALINK && !url.startsWith(ERR_PERMALINK));
46
+ }
47
+
48
+ export class BraintrustClient {
49
+ readonly config: TraceConfig;
50
+ readonly logger?: Logger;
51
+ sdkLogger?: BraintrustSdkLogger<true>;
52
+ initPromise?: Promise<void>;
53
+
54
+ constructor(config: TraceConfig, logger?: Logger) {
55
+ this.config = config;
56
+ this.logger = logger;
57
+ }
58
+
59
+ #ensureLogger(): BraintrustSdkLogger<true> {
60
+ if (this.sdkLogger) return this.sdkLogger;
61
+
62
+ if (this.config.apiUrl && !process.env.BRAINTRUST_API_URL) {
63
+ process.env.BRAINTRUST_API_URL = this.config.apiUrl;
64
+ }
65
+
66
+ this.sdkLogger = initLogger({
67
+ projectName: this.config.projectName,
68
+ apiKey: this.config.apiKey,
69
+ appUrl: this.config.appUrl,
70
+ orgName: this.config.orgName,
71
+ asyncFlush: true,
72
+ setCurrent: false,
73
+ debugLogLevel: this.config.debug ? "debug" : false,
74
+ });
75
+
76
+ return this.sdkLogger;
77
+ }
78
+
79
+ async initialize(): Promise<void> {
80
+ if (!this.initPromise) {
81
+ this.initPromise = this.#doInitialize();
82
+ }
83
+ return this.initPromise;
84
+ }
85
+
86
+ async #doInitialize(): Promise<void> {
87
+ if (!this.config.apiKey) {
88
+ throw new Error("BRAINTRUST_API_KEY is not set");
89
+ }
90
+
91
+ const sdkLogger = this.#ensureLogger();
92
+ await sdkLogger.id;
93
+ const project = await sdkLogger.project;
94
+ this.logger?.info("braintrust sdk logger initialized", {
95
+ appUrl: this.config.appUrl,
96
+ apiUrl: this.config.apiUrl,
97
+ project: project.name,
98
+ projectId: project.id,
99
+ });
100
+ }
101
+
102
+ startSpan(args: StartTraceSpanArgs): BraintrustSpanHandle | undefined {
103
+ try {
104
+ const sdkLogger = this.#ensureLogger();
105
+ const span = sdkLogger.startSpan({
106
+ spanId: args.spanId,
107
+ parentSpanIds: args.parentSpanId
108
+ ? {
109
+ spanId: args.parentSpanId,
110
+ rootSpanId: args.rootSpanId,
111
+ }
112
+ : undefined,
113
+ name: args.name,
114
+ type: args.type,
115
+ startTime: toUnixSeconds(args.startedAt),
116
+ event: compactRecord({
117
+ input: args.input,
118
+ output: args.output,
119
+ error: args.error,
120
+ metadata: args.metadata,
121
+ metrics: args.metrics,
122
+ }),
123
+ });
124
+ return span;
125
+ } catch (error) {
126
+ this.logger?.error("failed to start Braintrust span", {
127
+ error: String(error),
128
+ spanId: args.spanId,
129
+ parentSpanId: args.parentSpanId,
130
+ rootSpanId: args.rootSpanId,
131
+ name: args.name,
132
+ type: args.type,
133
+ });
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ logSpan(span: BraintrustSpanHandle | undefined, event: Omit<UpdateTraceSpanArgs, "id">): void {
139
+ if (!span) return;
140
+
141
+ try {
142
+ span.log(
143
+ compactRecord({
144
+ input: event.input,
145
+ output: event.output,
146
+ error: event.error,
147
+ metadata: event.metadata,
148
+ metrics: event.metrics,
149
+ }),
150
+ );
151
+ } catch (error) {
152
+ this.logger?.error("failed to log Braintrust span", {
153
+ error: String(error),
154
+ spanId: span.spanId,
155
+ rootSpanId: span.rootSpanId,
156
+ });
157
+ }
158
+ }
159
+
160
+ endSpan(span: BraintrustSpanHandle | undefined, endedAt = Date.now()): void {
161
+ if (!span) return;
162
+
163
+ try {
164
+ span.end({ endTime: toUnixSeconds(endedAt) });
165
+ } catch (error) {
166
+ this.logger?.error("failed to end Braintrust span", {
167
+ error: String(error),
168
+ spanId: span.spanId,
169
+ rootSpanId: span.rootSpanId,
170
+ });
171
+ }
172
+ }
173
+
174
+ getSpanLink(span: BraintrustSpanHandle | undefined): string | undefined {
175
+ if (!span) return undefined;
176
+
177
+ try {
178
+ const link = span.link();
179
+ return isUsablePermalink(link) ? link : undefined;
180
+ } catch (error) {
181
+ this.logger?.warn("failed to build Braintrust span link", {
182
+ error: String(error),
183
+ spanId: span.spanId,
184
+ rootSpanId: span.rootSpanId,
185
+ });
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ async getSpanPermalink(span: BraintrustSpanHandle | undefined): Promise<string | undefined> {
191
+ if (!span) return undefined;
192
+
193
+ const link = this.getSpanLink(span);
194
+ if (link) return link;
195
+
196
+ try {
197
+ const permalink = await span.permalink();
198
+ return isUsablePermalink(permalink) ? permalink : undefined;
199
+ } catch (error) {
200
+ this.logger?.warn("failed to build Braintrust span permalink", {
201
+ error: String(error),
202
+ spanId: span.spanId,
203
+ rootSpanId: span.rootSpanId,
204
+ });
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ updateSpan(args: UpdateTraceSpanArgs): void {
210
+ try {
211
+ const sdkLogger = this.#ensureLogger();
212
+ sdkLogger.updateSpan({
213
+ id: args.id,
214
+ span_id: args.spanId,
215
+ root_span_id: args.rootSpanId,
216
+ input: args.input,
217
+ output: args.output,
218
+ error: args.error,
219
+ metadata: args.metadata,
220
+ metrics: args.metrics,
221
+ });
222
+ } catch (error) {
223
+ this.logger?.error("failed to update Braintrust span", {
224
+ error: String(error),
225
+ id: args.id,
226
+ spanId: args.spanId,
227
+ rootSpanId: args.rootSpanId,
228
+ });
229
+ }
230
+ }
231
+
232
+ async flush(): Promise<void> {
233
+ const sdkLogger = this.sdkLogger;
234
+ if (!sdkLogger) return;
235
+
236
+ try {
237
+ await sdkLogger.flush();
238
+ } catch (error) {
239
+ this.logger?.error("failed to flush Braintrust logs", {
240
+ error: String(error),
241
+ });
242
+ }
243
+ }
244
+ }