@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/src/config.ts ADDED
@@ -0,0 +1,461 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import * as v from "valibot";
5
+ import type { ConfigIssue, JsonObject, Logger, LogLevel, TraceConfig } from "./types.ts";
6
+ import { ensureDir, writeJsonLog } from "./utils.ts";
7
+
8
+ const DEFAULT_STATE_DIR = join(homedir(), ".pi", "agent", "state", "braintrust-pi-extension");
9
+
10
+ const HTTP_URL_SCHEMA = v.pipe(
11
+ v.string(),
12
+ v.url(),
13
+ v.check((value) => {
14
+ try {
15
+ const protocol = new URL(value).protocol;
16
+ return protocol === "http:" || protocol === "https:";
17
+ } catch {
18
+ return false;
19
+ }
20
+ }, "must use http:// or https://"),
21
+ );
22
+ const JSON_OBJECT_SCHEMA = v.custom<JsonObject>(
23
+ (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value),
24
+ "must be a JSON object",
25
+ );
26
+ const NON_EMPTY_STRING_SCHEMA = v.pipe(v.string(), v.minLength(1));
27
+ const STRING_SCHEMA = v.string();
28
+
29
+ interface ConfigFileResult {
30
+ value?: JsonObject;
31
+ error?: string;
32
+ }
33
+
34
+ interface ApplyConfigResult {
35
+ parentSpanConfigured: boolean;
36
+ rootSpanConfigured: boolean;
37
+ }
38
+
39
+ function readConfigFile(path: string): ConfigFileResult {
40
+ if (!existsSync(path)) return {};
41
+
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
45
+ return {
46
+ error: "expected a JSON object",
47
+ };
48
+ }
49
+ return { value: parsed as JsonObject };
50
+ } catch (error) {
51
+ return {
52
+ error: error instanceof Error ? error.message : String(error),
53
+ };
54
+ }
55
+ }
56
+
57
+ function pushConfigIssue(
58
+ issues: ConfigIssue[],
59
+ path: string,
60
+ message: string,
61
+ severity: ConfigIssue["severity"] = "error",
62
+ ): void {
63
+ issues.push({ path, message, severity });
64
+ }
65
+
66
+ function validateOptionalString(
67
+ value: unknown,
68
+ issues: ConfigIssue[],
69
+ path: string,
70
+ key: string,
71
+ ): string | undefined {
72
+ if (value === undefined) return undefined;
73
+ const parsed = v.safeParse(STRING_SCHEMA, value);
74
+ if (!parsed.success) {
75
+ pushConfigIssue(issues, path, `${key} must be a string`);
76
+ return undefined;
77
+ }
78
+ return parsed.output;
79
+ }
80
+
81
+ function validateOptionalNonEmptyString(
82
+ value: unknown,
83
+ issues: ConfigIssue[],
84
+ path: string,
85
+ key: string,
86
+ ): string | undefined {
87
+ if (value === undefined) return undefined;
88
+ const parsed = v.safeParse(NON_EMPTY_STRING_SCHEMA, value);
89
+ if (!parsed.success) {
90
+ pushConfigIssue(issues, path, `${key} must be a non-empty string`);
91
+ return undefined;
92
+ }
93
+ return parsed.output;
94
+ }
95
+
96
+ function validateOptionalBoolean(
97
+ value: unknown,
98
+ issues: ConfigIssue[],
99
+ path: string,
100
+ key: string,
101
+ ): boolean | undefined {
102
+ if (value === undefined) return undefined;
103
+ if (typeof value === "boolean") return value;
104
+
105
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
106
+ const normalized = String(value).trim().toLowerCase();
107
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
108
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
109
+ }
110
+
111
+ pushConfigIssue(issues, path, `${key} must be a boolean`);
112
+ return undefined;
113
+ }
114
+
115
+ function validateOptionalUrl(
116
+ value: unknown,
117
+ issues: ConfigIssue[],
118
+ path: string,
119
+ key: string,
120
+ ): string | undefined {
121
+ if (value === undefined) return undefined;
122
+
123
+ const stringValue = validateOptionalString(value, issues, path, key);
124
+ if (stringValue === undefined) return undefined;
125
+
126
+ const parsed = v.safeParse(HTTP_URL_SCHEMA, stringValue);
127
+ if (!parsed.success) {
128
+ pushConfigIssue(issues, path, `${key} must be a valid http(s) URL`);
129
+ return undefined;
130
+ }
131
+
132
+ return parsed.output;
133
+ }
134
+
135
+ function validateOptionalMetadata(
136
+ value: unknown,
137
+ issues: ConfigIssue[],
138
+ path: string,
139
+ key: string,
140
+ ): JsonObject | undefined {
141
+ if (value === undefined) return undefined;
142
+
143
+ const parsed = v.safeParse(JSON_OBJECT_SCHEMA, value);
144
+ if (!parsed.success) {
145
+ pushConfigIssue(issues, path, `${key} must be a JSON object`);
146
+ return undefined;
147
+ }
148
+
149
+ return parsed.output as JsonObject;
150
+ }
151
+
152
+ function applyConfig(
153
+ target: TraceConfig,
154
+ source: JsonObject | undefined,
155
+ path: string,
156
+ issues: ConfigIssue[],
157
+ ): ApplyConfigResult {
158
+ if (!source) {
159
+ return {
160
+ parentSpanConfigured: false,
161
+ rootSpanConfigured: false,
162
+ };
163
+ }
164
+
165
+ const apiKey = validateOptionalString(source.api_key, issues, path, "api_key");
166
+ if (apiKey !== undefined) target.apiKey = apiKey;
167
+
168
+ const apiUrl = validateOptionalUrl(source.api_url, issues, path, "api_url");
169
+ if (apiUrl !== undefined) target.apiUrl = apiUrl;
170
+
171
+ const appUrl = validateOptionalUrl(source.app_url, issues, path, "app_url");
172
+ if (appUrl !== undefined) target.appUrl = appUrl;
173
+
174
+ const orgName = validateOptionalNonEmptyString(source.org_name, issues, path, "org_name");
175
+ if (orgName !== undefined) target.orgName = orgName;
176
+
177
+ const projectName = validateOptionalNonEmptyString(source.project, issues, path, "project");
178
+ if (projectName !== undefined) target.projectName = projectName;
179
+
180
+ const enabled = validateOptionalBoolean(
181
+ source.trace_to_braintrust,
182
+ issues,
183
+ path,
184
+ "trace_to_braintrust",
185
+ );
186
+ if (enabled !== undefined) target.enabled = enabled;
187
+
188
+ const debug = validateOptionalBoolean(source.debug, issues, path, "debug");
189
+ if (debug !== undefined) target.debug = debug;
190
+
191
+ const logFile = validateOptionalString(source.log_file, issues, path, "log_file");
192
+ if (logFile !== undefined) target.logFile = logFile;
193
+
194
+ const stateDir = validateOptionalNonEmptyString(source.state_dir, issues, path, "state_dir");
195
+ if (stateDir !== undefined) target.stateDir = stateDir;
196
+
197
+ const parentSpanId = validateOptionalNonEmptyString(
198
+ source.parent_span_id,
199
+ issues,
200
+ path,
201
+ "parent_span_id",
202
+ );
203
+ if (parentSpanId !== undefined) target.parentSpanId = parentSpanId;
204
+
205
+ const rootSpanId = validateOptionalNonEmptyString(
206
+ source.root_span_id,
207
+ issues,
208
+ path,
209
+ "root_span_id",
210
+ );
211
+ if (rootSpanId !== undefined) target.rootSpanId = rootSpanId;
212
+
213
+ const additionalMetadata = validateOptionalMetadata(
214
+ source.additional_metadata,
215
+ issues,
216
+ path,
217
+ "additional_metadata",
218
+ );
219
+ if (additionalMetadata !== undefined) target.additionalMetadata = additionalMetadata;
220
+
221
+ return {
222
+ parentSpanConfigured: parentSpanId !== undefined,
223
+ rootSpanConfigured: rootSpanId !== undefined,
224
+ };
225
+ }
226
+
227
+ export function loadConfig(cwd = process.cwd()): TraceConfig {
228
+ const config: TraceConfig = {
229
+ enabled: false,
230
+ apiKey: "",
231
+ apiUrl: undefined,
232
+ appUrl: "https://www.braintrust.dev",
233
+ orgName: undefined,
234
+ projectName: "pi",
235
+ debug: false,
236
+ logFile: undefined,
237
+ stateDir: DEFAULT_STATE_DIR,
238
+ additionalMetadata: {},
239
+ parentSpanId: undefined,
240
+ rootSpanId: undefined,
241
+ configIssues: [],
242
+ };
243
+
244
+ const globalConfigPath = join(homedir(), ".pi", "agent", "braintrust.json");
245
+ const projectConfigPath = join(cwd, ".pi", "braintrust.json");
246
+
247
+ let parentSpanConfigured = false;
248
+ let rootSpanConfigured = false;
249
+
250
+ const globalConfig = readConfigFile(globalConfigPath);
251
+ if (globalConfig.error) {
252
+ pushConfigIssue(config.configIssues, globalConfigPath, globalConfig.error);
253
+ }
254
+ {
255
+ const applied = applyConfig(config, globalConfig.value, globalConfigPath, config.configIssues);
256
+ parentSpanConfigured ||= applied.parentSpanConfigured;
257
+ rootSpanConfigured ||= applied.rootSpanConfigured;
258
+ }
259
+
260
+ const projectConfig = readConfigFile(projectConfigPath);
261
+ if (projectConfig.error) {
262
+ pushConfigIssue(config.configIssues, projectConfigPath, projectConfig.error);
263
+ }
264
+ {
265
+ const applied = applyConfig(
266
+ config,
267
+ projectConfig.value,
268
+ projectConfigPath,
269
+ config.configIssues,
270
+ );
271
+ parentSpanConfigured ||= applied.parentSpanConfigured;
272
+ rootSpanConfigured ||= applied.rootSpanConfigured;
273
+ }
274
+
275
+ const envApiKey = validateOptionalString(
276
+ process.env.BRAINTRUST_API_KEY,
277
+ config.configIssues,
278
+ "BRAINTRUST_API_KEY",
279
+ "BRAINTRUST_API_KEY",
280
+ );
281
+ if (envApiKey !== undefined) config.apiKey = envApiKey;
282
+
283
+ const envApiUrl = validateOptionalUrl(
284
+ process.env.BRAINTRUST_API_URL,
285
+ config.configIssues,
286
+ "BRAINTRUST_API_URL",
287
+ "BRAINTRUST_API_URL",
288
+ );
289
+ if (envApiUrl !== undefined) config.apiUrl = envApiUrl;
290
+
291
+ const envAppUrl = validateOptionalUrl(
292
+ process.env.BRAINTRUST_APP_URL,
293
+ config.configIssues,
294
+ "BRAINTRUST_APP_URL",
295
+ "BRAINTRUST_APP_URL",
296
+ );
297
+ if (envAppUrl !== undefined) config.appUrl = envAppUrl;
298
+
299
+ const envOrgName = validateOptionalNonEmptyString(
300
+ process.env.BRAINTRUST_ORG_NAME,
301
+ config.configIssues,
302
+ "BRAINTRUST_ORG_NAME",
303
+ "BRAINTRUST_ORG_NAME",
304
+ );
305
+ if (envOrgName !== undefined) config.orgName = envOrgName;
306
+
307
+ const envProjectName = validateOptionalNonEmptyString(
308
+ process.env.BRAINTRUST_PROJECT,
309
+ config.configIssues,
310
+ "BRAINTRUST_PROJECT",
311
+ "BRAINTRUST_PROJECT",
312
+ );
313
+ if (envProjectName !== undefined) config.projectName = envProjectName;
314
+
315
+ const envEnabled = validateOptionalBoolean(
316
+ process.env.TRACE_TO_BRAINTRUST,
317
+ config.configIssues,
318
+ "TRACE_TO_BRAINTRUST",
319
+ "TRACE_TO_BRAINTRUST",
320
+ );
321
+ if (envEnabled !== undefined) config.enabled = envEnabled;
322
+
323
+ const envDebug = validateOptionalBoolean(
324
+ process.env.BRAINTRUST_DEBUG,
325
+ config.configIssues,
326
+ "BRAINTRUST_DEBUG",
327
+ "BRAINTRUST_DEBUG",
328
+ );
329
+ if (envDebug !== undefined) config.debug = envDebug;
330
+
331
+ const envLogFile = validateOptionalString(
332
+ process.env.BRAINTRUST_LOG_FILE,
333
+ config.configIssues,
334
+ "BRAINTRUST_LOG_FILE",
335
+ "BRAINTRUST_LOG_FILE",
336
+ );
337
+ if (envLogFile !== undefined) config.logFile = envLogFile;
338
+
339
+ const envStateDir = validateOptionalNonEmptyString(
340
+ process.env.BRAINTRUST_STATE_DIR,
341
+ config.configIssues,
342
+ "BRAINTRUST_STATE_DIR",
343
+ "BRAINTRUST_STATE_DIR",
344
+ );
345
+ if (envStateDir !== undefined) config.stateDir = envStateDir;
346
+
347
+ const envParentSpanId = validateOptionalNonEmptyString(
348
+ process.env.PI_PARENT_SPAN_ID,
349
+ config.configIssues,
350
+ "PI_PARENT_SPAN_ID",
351
+ "PI_PARENT_SPAN_ID",
352
+ );
353
+ if (envParentSpanId !== undefined) {
354
+ config.parentSpanId = envParentSpanId;
355
+ parentSpanConfigured = true;
356
+ }
357
+
358
+ const envRootSpanId = validateOptionalNonEmptyString(
359
+ process.env.PI_ROOT_SPAN_ID,
360
+ config.configIssues,
361
+ "PI_ROOT_SPAN_ID",
362
+ "PI_ROOT_SPAN_ID",
363
+ );
364
+ if (envRootSpanId !== undefined) {
365
+ config.rootSpanId = envRootSpanId;
366
+ rootSpanConfigured = true;
367
+ }
368
+
369
+ if (process.env.BRAINTRUST_ADDITIONAL_METADATA !== undefined) {
370
+ try {
371
+ const parsed = JSON.parse(process.env.BRAINTRUST_ADDITIONAL_METADATA) as unknown;
372
+ const metadata = validateOptionalMetadata(
373
+ parsed,
374
+ config.configIssues,
375
+ "BRAINTRUST_ADDITIONAL_METADATA",
376
+ "BRAINTRUST_ADDITIONAL_METADATA",
377
+ );
378
+ if (metadata !== undefined) config.additionalMetadata = metadata;
379
+ } catch (error) {
380
+ pushConfigIssue(
381
+ config.configIssues,
382
+ "BRAINTRUST_ADDITIONAL_METADATA",
383
+ error instanceof Error ? error.message : String(error),
384
+ );
385
+ }
386
+ }
387
+
388
+ if (config.parentSpanId && !config.rootSpanId) config.rootSpanId = config.parentSpanId;
389
+ if (config.rootSpanId && !config.parentSpanId) config.parentSpanId = config.rootSpanId;
390
+
391
+ if (
392
+ parentSpanConfigured &&
393
+ rootSpanConfigured &&
394
+ config.parentSpanId &&
395
+ config.rootSpanId &&
396
+ config.parentSpanId === config.rootSpanId
397
+ ) {
398
+ pushConfigIssue(
399
+ config.configIssues,
400
+ "parent_span_id/root_span_id",
401
+ "parent_span_id and root_span_id are identical; set only one unless the parent span is also the trace root",
402
+ "warning",
403
+ );
404
+ }
405
+
406
+ if (config.enabled && !config.apiKey) {
407
+ pushConfigIssue(
408
+ config.configIssues,
409
+ "BRAINTRUST_API_KEY",
410
+ "TRACE_TO_BRAINTRUST is enabled but BRAINTRUST_API_KEY is not set",
411
+ "warning",
412
+ );
413
+ }
414
+
415
+ ensureDir(config.stateDir);
416
+ return config;
417
+ }
418
+
419
+ export function createLogger(config: TraceConfig): Logger {
420
+ const explicitLogFile =
421
+ config.logFile && config.logFile !== "true" && config.logFile !== "auto"
422
+ ? config.logFile
423
+ : join(config.stateDir, "braintrust-pi-extension.log");
424
+ const loggingEnabled = config.debug || Boolean(config.logFile);
425
+
426
+ if (loggingEnabled) ensureDir(dirname(explicitLogFile));
427
+
428
+ let pendingWrite = Promise.resolve();
429
+
430
+ function emit(level: LogLevel, message: string, data?: unknown): void {
431
+ if (!loggingEnabled) return;
432
+ pendingWrite = pendingWrite
433
+ .catch(() => {})
434
+ .then(async () => {
435
+ try {
436
+ await writeJsonLog(explicitLogFile, level, message, data);
437
+ } catch {
438
+ // Logging is best-effort and must never affect pi session execution.
439
+ }
440
+ });
441
+ }
442
+
443
+ return {
444
+ filePath: explicitLogFile,
445
+ debug(message, data) {
446
+ if (config.debug) emit("debug", message, data);
447
+ },
448
+ info(message, data) {
449
+ emit("info", message, data);
450
+ },
451
+ warn(message, data) {
452
+ emit("warn", message, data);
453
+ },
454
+ error(message, data) {
455
+ emit("error", message, data);
456
+ },
457
+ async flush() {
458
+ await pendingWrite.catch(() => {});
459
+ },
460
+ };
461
+ }