Skip to content

Commit cf778dd

Browse files
authored
fix(auto-instrumentation): Fully preserve returned promise in tracing channel hook (#1617)
Fix the auto-instrumentation node hook so Anthropic `messages.stream()` keeps working under `node --import braintrust/hook.mjs`. - Preserve the original promise-like return value in the hook instead of returning a chained promise - Keep Anthropic `APIPromise` helpers like `withResponse()` intact - Remove the e2e workaround so the Anthropic node-hook scenario uses `messages.stream()` again - Add regression coverage for promise subclass helper preservation Fixes #1591
1 parent 633d6da commit cf778dd

4 files changed

Lines changed: 141 additions & 10 deletions

File tree

js/src/auto-instrumentations/patch-tracing-channel.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ class MockAPIPromise<T> extends Promise<T> {
135135
return this.#innerPromise.then(onfulfilled, onrejected);
136136
}
137137

138+
async withResponse() {
139+
return {
140+
data: await this.#innerPromise,
141+
response: { ok: true },
142+
};
143+
}
144+
138145
// Symbol.species returns a class that throws, reproducing the exact error seen
139146
// when Node.js's PromisePrototypeThen tries to construct the result promise.
140147
static override get [Symbol.species](): PromiseConstructor {
@@ -180,6 +187,20 @@ describe("patchTracingChannel", () => {
180187
expect(context.result).toBe("hello");
181188
});
182189

190+
it("patched tracePromise preserves helper methods on promise subclasses", async () => {
191+
const FakeTCClass = makeUnpatchedTracingChannel();
192+
const channel = new FakeTCClass();
193+
patchTracingChannel(() => channel);
194+
195+
const apiPromise = new MockAPIPromise(Promise.resolve("hello"));
196+
const traced = channel.tracePromise(() => apiPromise, {}, null);
197+
const withResponse = await traced.withResponse();
198+
199+
expect(traced).toBe(apiPromise);
200+
expect(withResponse.data).toBe("hello");
201+
expect(withResponse.response.ok).toBe(true);
202+
});
203+
183204
it("patched tracePromise correctly handles plain async functions", async () => {
184205
const FakeTCClass = makeUnpatchedTracingChannel();
185206
const channel = new FakeTCClass();

js/src/auto-instrumentations/patch-tracing-channel.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,18 @@ export function patchTracingChannel(
5959
const { start, end, asyncStart, asyncEnd, error } = this;
6060

6161
// eslint-disable-next-line @typescript-eslint/no-explicit-any
62-
function reject(err: any) {
62+
function publishRejected(err: any) {
6363
context.error = err;
6464
error?.publish(context);
6565
asyncStart?.publish(context);
6666
asyncEnd?.publish(context);
67-
return Promise.reject(err);
6867
}
6968

7069
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71-
function resolve(result: any) {
70+
function publishResolved(result: any) {
7271
context.result = result;
7372
asyncStart?.publish(context);
7473
asyncEnd?.publish(context);
75-
return result;
7674
}
7775

7876
// Use runStores (not just publish) so fn runs inside the ALS context
@@ -91,7 +89,41 @@ export function patchTracingChannel(
9189
(typeof result === "object" || typeof result === "function") &&
9290
typeof result.then === "function"
9391
) {
94-
return result.then(resolve, reject);
92+
if (result.constructor === Promise) {
93+
return result.then(
94+
(res) => {
95+
publishResolved(res);
96+
return res;
97+
},
98+
(err) => {
99+
publishRejected(err);
100+
return Promise.reject(err);
101+
},
102+
);
103+
}
104+
105+
// Preserve the original promise-like object so SDK helper methods
106+
// like Anthropic APIPromise.withResponse() remain available.
107+
void result.then(
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
(resolved: any) => {
110+
try {
111+
publishResolved(resolved);
112+
} catch {
113+
// Preserve wrapped promise semantics even if instrumentation fails.
114+
}
115+
},
116+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117+
(err: any) => {
118+
try {
119+
publishRejected(err);
120+
} catch {
121+
// Preserve wrapped promise semantics even if instrumentation fails.
122+
}
123+
},
124+
);
125+
126+
return result;
95127
}
96128

97129
context.result = result;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { tracingChannel } from "node:diagnostics_channel";
2+
import { parentPort } from "node:worker_threads";
3+
4+
class HelperPromise extends Promise {
5+
#innerPromise;
6+
7+
constructor(responsePromise) {
8+
let resolveOuter;
9+
super((resolve) => {
10+
resolveOuter = resolve;
11+
});
12+
this.#innerPromise = Promise.resolve(responsePromise);
13+
this.#innerPromise.then(resolveOuter);
14+
}
15+
16+
then(onfulfilled, onrejected) {
17+
return this.#innerPromise.then(onfulfilled, onrejected);
18+
}
19+
20+
async withResponse() {
21+
return {
22+
data: await this.#innerPromise,
23+
response: { ok: true },
24+
};
25+
}
26+
}
27+
28+
const channel = tracingChannel("braintrust:test:helper-promise");
29+
const traced = channel.tracePromise(
30+
() => new HelperPromise(Promise.resolve("ok")),
31+
{},
32+
);
33+
34+
const withResponse = await traced.withResponse();
35+
36+
parentPort?.postMessage({
37+
type: "helper-result",
38+
result: {
39+
awaitedValue: await traced,
40+
constructorName: traced.constructor.name,
41+
hasWithResponse: typeof traced.withResponse === "function",
42+
withResponseData: withResponse.data,
43+
withResponseOk: withResponse.response.ok,
44+
},
45+
});

js/tests/auto-instrumentations/loader-hook.test.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { describe, it, expect, beforeAll, afterAll } from "vitest";
22
import { Worker } from "node:worker_threads";
3-
import * as fs from "node:fs";
43
import * as path from "node:path";
54
import { fileURLToPath, pathToFileURL } from "node:url";
65

76
const __dirname = path.dirname(fileURLToPath(import.meta.url));
87
const fixturesDir = path.join(__dirname, "fixtures");
9-
const nodeModulesDir = path.join(fixturesDir, "node_modules");
108

119
// Path to unified loader hook (built dist file)
1210
const hookPath = path.join(
@@ -18,6 +16,10 @@ const hookPath = path.join(
1816
const listenerPath = path.join(fixturesDir, "listener-esm.mjs");
1917
const testAppEsmPath = path.join(fixturesDir, "test-app-esm.mjs");
2018
const testAppCjsPath = path.join(fixturesDir, "test-app-cjs.cjs");
19+
const helperPromisePath = path.join(
20+
fixturesDir,
21+
"test-api-promise-preservation.mjs",
22+
);
2123

2224
interface TestResult {
2325
events: { start: any[]; end: any[]; error: any[] };
@@ -53,15 +55,46 @@ describe("Unified Loader Hook Integration Tests", () => {
5355
expect(result.events.start.length).toBeGreaterThan(0);
5456
expect(result.events.end.length).toBeGreaterThan(0);
5557
});
58+
59+
it("should preserve helper methods on promise subclasses", async () => {
60+
const result = await runWithWorkerMessage<{
61+
awaitedValue: string;
62+
constructorName: string;
63+
hasWithResponse: boolean;
64+
withResponseData: string;
65+
withResponseOk: boolean;
66+
}>({
67+
execArgv: ["--import", hookPath],
68+
messageType: "helper-result",
69+
script: helperPromisePath,
70+
});
71+
72+
expect(result.hasWithResponse).toBe(true);
73+
expect(result.awaitedValue).toBe("ok");
74+
expect(result.withResponseData).toBe("ok");
75+
expect(result.withResponseOk).toBe(true);
76+
expect(result.constructorName).toBe("HelperPromise");
77+
});
5678
});
5779
});
5880

5981
async function runWithWorker(options: {
6082
execArgv: string[];
6183
script: string;
6284
}): Promise<TestResult> {
85+
return runWithWorkerMessage({
86+
...options,
87+
messageType: "events",
88+
});
89+
}
90+
91+
async function runWithWorkerMessage<T>(options: {
92+
execArgv: string[];
93+
messageType: string;
94+
script: string;
95+
}): Promise<T> {
6396
return new Promise((resolve, reject) => {
64-
let result: TestResult | null = null;
97+
let result: T | null = null;
6598

6699
// Convert execArgv paths to file URLs on Windows
67100
// On Windows, Node.js --import requires file:// URLs
@@ -94,8 +127,8 @@ async function runWithWorker(options: {
94127
});
95128

96129
worker.on("message", (msg) => {
97-
if (msg.type === "events") {
98-
result = { events: msg.events };
130+
if (msg.type === options.messageType) {
131+
result = (msg.result ?? { events: msg.events }) as T;
99132
}
100133
});
101134

0 commit comments

Comments
 (0)