@goharvest/simforge 0.4.7 → 0.5.2
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/dist/client.d.ts +152 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +342 -219
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +857 -62
- package/dist/client.test.js.map +1 -1
- package/dist/http.d.ts +73 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +192 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/serialize.d.ts +55 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +68 -0
- package/dist/serialize.js.map +1 -0
- package/dist/serialize.test.d.ts +8 -0
- package/dist/serialize.test.d.ts.map +1 -0
- package/dist/serialize.test.js +226 -0
- package/dist/serialize.test.js.map +1 -0
- package/dist/tracing.d.ts +6 -5
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +32 -126
- package/dist/tracing.js.map +1 -1
- package/dist/tracing.test.js +1 -1
- package/dist/tracing.test.js.map +1 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +4 -2
package/dist/client.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import * as baml from "./baml.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getCurrentSpan, Simforge } from "./client";
|
|
4
4
|
// Mock fetch globally
|
|
5
5
|
globalThis.fetch = vi.fn();
|
|
6
6
|
// Mock BAML execution
|
|
@@ -16,9 +16,30 @@ describe("Simforge Client", () => {
|
|
|
16
16
|
const client = new Simforge({ apiKey: "test-key" });
|
|
17
17
|
expect(client).toBeInstanceOf(Simforge);
|
|
18
18
|
});
|
|
19
|
-
it("should
|
|
19
|
+
it("should auto-disable tracing with empty apiKey and log warning", () => {
|
|
20
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
20
21
|
const client = new Simforge({ apiKey: "" });
|
|
21
22
|
expect(client).toBeInstanceOf(Simforge);
|
|
23
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("apiKey is empty"));
|
|
24
|
+
// Should behave as disabled — withSpan returns the original function
|
|
25
|
+
const fn = client.withSpan("test-key", () => "result");
|
|
26
|
+
expect(fn()).toBe("result");
|
|
27
|
+
warnSpy.mockRestore();
|
|
28
|
+
});
|
|
29
|
+
it("should auto-disable tracing with whitespace apiKey", () => {
|
|
30
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
31
|
+
const client = new Simforge({ apiKey: " " });
|
|
32
|
+
const fn = client.withSpan("test-key", () => "result");
|
|
33
|
+
expect(fn()).toBe("result");
|
|
34
|
+
warnSpy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
it("should not warn when explicitly disabled with empty apiKey", () => {
|
|
37
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
38
|
+
const client = new Simforge({ apiKey: "", enabled: false });
|
|
39
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
40
|
+
const fn = client.withSpan("test-key", () => "result");
|
|
41
|
+
expect(fn()).toBe("result");
|
|
42
|
+
warnSpy.mockRestore();
|
|
22
43
|
});
|
|
23
44
|
it("should use custom serviceUrl", () => {
|
|
24
45
|
const client = new Simforge({
|
|
@@ -41,14 +62,7 @@ describe("Simforge Client", () => {
|
|
|
41
62
|
});
|
|
42
63
|
expect(client).toBeInstanceOf(Simforge);
|
|
43
64
|
});
|
|
44
|
-
|
|
45
|
-
const client = new Simforge({
|
|
46
|
-
apiKey: "test-key",
|
|
47
|
-
executeLocally: false,
|
|
48
|
-
});
|
|
49
|
-
expect(client).toBeInstanceOf(Simforge);
|
|
50
|
-
});
|
|
51
|
-
describe("call method - local execution", () => {
|
|
65
|
+
describe("call method", () => {
|
|
52
66
|
beforeEach(() => {
|
|
53
67
|
// Reset BAML mock
|
|
54
68
|
vi.mocked(baml.runFunctionWithBaml).mockReset();
|
|
@@ -118,7 +132,6 @@ function ExtractName(text: string) -> Name {
|
|
|
118
132
|
});
|
|
119
133
|
const client = new Simforge({
|
|
120
134
|
apiKey: "test-key",
|
|
121
|
-
executeLocally: true,
|
|
122
135
|
envVars: { OPENAI_API_KEY: "test-openai-key" },
|
|
123
136
|
});
|
|
124
137
|
const result = await client.call("ExtractName", {
|
|
@@ -151,7 +164,6 @@ function ExtractName(text: string) -> Name {
|
|
|
151
164
|
vi.mocked(baml.runFunctionWithBaml).mockRejectedValueOnce(new Error("Invalid argument: Expected type String, got Number(12345)"));
|
|
152
165
|
const client = new Simforge({
|
|
153
166
|
apiKey: "test-key",
|
|
154
|
-
executeLocally: true,
|
|
155
167
|
});
|
|
156
168
|
await expect(
|
|
157
169
|
// biome-ignore lint/suspicious/noExplicitAny: Testing wrong type handling
|
|
@@ -175,7 +187,6 @@ function ExtractName(text: string) -> Name {
|
|
|
175
187
|
vi.mocked(baml.runFunctionWithBaml).mockRejectedValueOnce(new Error("Missing required parameter: text"));
|
|
176
188
|
const client = new Simforge({
|
|
177
189
|
apiKey: "test-key",
|
|
178
|
-
executeLocally: true,
|
|
179
190
|
});
|
|
180
191
|
await expect(client.call("ExtractName", {})).rejects.toThrow("Missing required parameter");
|
|
181
192
|
});
|
|
@@ -232,7 +243,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
232
243
|
});
|
|
233
244
|
const client = new Simforge({
|
|
234
245
|
apiKey: "test-key",
|
|
235
|
-
executeLocally: true,
|
|
236
246
|
});
|
|
237
247
|
const result = (await client.call("ExtractPerson", {
|
|
238
248
|
text: "John Doe, 30, john@example.com, 123 Main St, New York, 10001",
|
|
@@ -262,7 +272,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
262
272
|
});
|
|
263
273
|
const client = new Simforge({
|
|
264
274
|
apiKey: "test-key",
|
|
265
|
-
executeLocally: true,
|
|
266
275
|
});
|
|
267
276
|
await expect(client.call("ExtractName", { text: "test" })).rejects.toThrow("has no prompt");
|
|
268
277
|
});
|
|
@@ -295,7 +304,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
295
304
|
});
|
|
296
305
|
const client = new Simforge({
|
|
297
306
|
apiKey: "test-key",
|
|
298
|
-
executeLocally: true,
|
|
299
307
|
envVars: {
|
|
300
308
|
OPENAI_API_KEY: "test-key",
|
|
301
309
|
},
|
|
@@ -309,50 +317,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
309
317
|
});
|
|
310
318
|
});
|
|
311
319
|
});
|
|
312
|
-
describe("call method - server-side execution", () => {
|
|
313
|
-
it("should make successful API call", async () => {
|
|
314
|
-
const mockResponse = "success";
|
|
315
|
-
globalThis.fetch.mockResolvedValueOnce({
|
|
316
|
-
ok: true,
|
|
317
|
-
json: async () => ({ result: mockResponse }),
|
|
318
|
-
});
|
|
319
|
-
const client = new Simforge({
|
|
320
|
-
apiKey: "test-key",
|
|
321
|
-
executeLocally: false,
|
|
322
|
-
});
|
|
323
|
-
const result = await client.call("testMethod", { input: "test" });
|
|
324
|
-
expect(result).toEqual(mockResponse);
|
|
325
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/sdk/call"), expect.objectContaining({
|
|
326
|
-
method: "POST",
|
|
327
|
-
headers: expect.objectContaining({
|
|
328
|
-
Authorization: "Bearer test-key",
|
|
329
|
-
"Content-Type": "application/json",
|
|
330
|
-
}),
|
|
331
|
-
}));
|
|
332
|
-
});
|
|
333
|
-
it("should handle API errors", async () => {
|
|
334
|
-
;
|
|
335
|
-
globalThis.fetch.mockResolvedValueOnce({
|
|
336
|
-
ok: false,
|
|
337
|
-
status: 400,
|
|
338
|
-
json: async () => ({ error: "Bad request" }),
|
|
339
|
-
});
|
|
340
|
-
const client = new Simforge({
|
|
341
|
-
apiKey: "test-key",
|
|
342
|
-
executeLocally: false,
|
|
343
|
-
});
|
|
344
|
-
await expect(client.call("testMethod", {})).rejects.toThrow(SimforgeError);
|
|
345
|
-
});
|
|
346
|
-
it("should handle network errors", async () => {
|
|
347
|
-
;
|
|
348
|
-
globalThis.fetch.mockRejectedValueOnce(new Error("Network error"));
|
|
349
|
-
const client = new Simforge({
|
|
350
|
-
apiKey: "test-key",
|
|
351
|
-
executeLocally: false,
|
|
352
|
-
});
|
|
353
|
-
await expect(client.call("testMethod", {})).rejects.toThrow(SimforgeError);
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
320
|
describe("trace creation with source field", () => {
|
|
357
321
|
it("should include source='typescript-sdk' by default when creating traces", async () => {
|
|
358
322
|
const mockFetch = vi.mocked(fetch);
|
|
@@ -387,7 +351,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
387
351
|
});
|
|
388
352
|
const client = new Simforge({
|
|
389
353
|
apiKey: "test-key",
|
|
390
|
-
executeLocally: true,
|
|
391
354
|
});
|
|
392
355
|
await client.call("testMethod", {});
|
|
393
356
|
// Verify trace creation was called with source field
|
|
@@ -440,7 +403,6 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
440
403
|
});
|
|
441
404
|
const client = new Simforge({
|
|
442
405
|
apiKey: "test-key",
|
|
443
|
-
executeLocally: true,
|
|
444
406
|
});
|
|
445
407
|
await client.call("testMethod", {});
|
|
446
408
|
// Verify trace creation includes both source and rawCollector
|
|
@@ -450,5 +412,838 @@ function ExtractPerson(text: string) -> Person {}
|
|
|
450
412
|
expect(traceBody.rawCollector).toEqual(mockCollector);
|
|
451
413
|
});
|
|
452
414
|
});
|
|
415
|
+
describe("withSpan", () => {
|
|
416
|
+
it("should wrap an async function and trace its execution", async () => {
|
|
417
|
+
const mockFetch = vi.mocked(fetch);
|
|
418
|
+
// Mock wrapper trace creation
|
|
419
|
+
mockFetch.mockResolvedValueOnce({
|
|
420
|
+
ok: true,
|
|
421
|
+
status: 200,
|
|
422
|
+
json: async () => ({ traceId: "wrapper-trace-123" }),
|
|
423
|
+
});
|
|
424
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
425
|
+
const originalFn = async (name, count) => {
|
|
426
|
+
return { greeting: `Hello ${name}!`, count };
|
|
427
|
+
};
|
|
428
|
+
const wrappedFn = client.withSpan("greeting-service", originalFn);
|
|
429
|
+
const result = await wrappedFn("World", 42);
|
|
430
|
+
// Verify the function returns the correct result
|
|
431
|
+
expect(result).toEqual({ greeting: "Hello World!", count: 42 });
|
|
432
|
+
// Verify wrapper trace was created
|
|
433
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
434
|
+
expect(traceCall).toBeDefined();
|
|
435
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
436
|
+
expect(traceBody.traceFunctionKey).toBe("greeting-service");
|
|
437
|
+
expect(traceBody.source).toBe("typescript-sdk-function");
|
|
438
|
+
// Inputs and result are in externalSpan.span_data
|
|
439
|
+
expect(traceBody.rawSpan.span_data.input).toEqual(["World", 42]);
|
|
440
|
+
expect(traceBody.rawSpan.span_data.output).toEqual({
|
|
441
|
+
greeting: "Hello World!",
|
|
442
|
+
count: 42,
|
|
443
|
+
});
|
|
444
|
+
// No meta needed for basic types
|
|
445
|
+
expect(traceBody.rawSpan.span_data.input_meta).toBeUndefined();
|
|
446
|
+
expect(traceBody.rawSpan.span_data.output_meta).toBeUndefined();
|
|
447
|
+
});
|
|
448
|
+
it("should wrap a sync function and trace its execution", async () => {
|
|
449
|
+
const mockFetch = vi.mocked(fetch);
|
|
450
|
+
// Mock wrapper trace creation
|
|
451
|
+
mockFetch.mockResolvedValueOnce({
|
|
452
|
+
ok: true,
|
|
453
|
+
status: 200,
|
|
454
|
+
json: async () => ({ traceId: "wrapper-trace-456" }),
|
|
455
|
+
});
|
|
456
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
457
|
+
const originalFn = (a, b) => a + b;
|
|
458
|
+
const wrappedFn = client.withSpan("calculator", originalFn);
|
|
459
|
+
const result = wrappedFn(5, 3);
|
|
460
|
+
// Verify the function returns the correct result
|
|
461
|
+
expect(result).toBe(8);
|
|
462
|
+
// Wait for the background trace to be sent
|
|
463
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
464
|
+
// Verify wrapper trace was created
|
|
465
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
466
|
+
expect(traceCall).toBeDefined();
|
|
467
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
468
|
+
expect(traceBody.traceFunctionKey).toBe("calculator");
|
|
469
|
+
// Result is in externalSpan.span_data.output
|
|
470
|
+
expect(traceBody.rawSpan.span_data.output).toBe(8);
|
|
471
|
+
});
|
|
472
|
+
it("should preserve the original function's return type", async () => {
|
|
473
|
+
const mockFetch = vi.mocked(fetch);
|
|
474
|
+
mockFetch.mockResolvedValueOnce({
|
|
475
|
+
ok: true,
|
|
476
|
+
status: 200,
|
|
477
|
+
json: async () => ({ traceId: "wrapper-trace-789" }),
|
|
478
|
+
});
|
|
479
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
480
|
+
const getUser = async (id) => ({
|
|
481
|
+
id,
|
|
482
|
+
name: "Test User",
|
|
483
|
+
});
|
|
484
|
+
const wrappedGetUser = client.withSpan("user-service", getUser);
|
|
485
|
+
// TypeScript should know the return type is Promise<User>
|
|
486
|
+
const user = await wrappedGetUser("user-123");
|
|
487
|
+
expect(user.id).toBe("user-123");
|
|
488
|
+
expect(user.name).toBe("Test User");
|
|
489
|
+
});
|
|
490
|
+
it("should handle string results without double-serialization", async () => {
|
|
491
|
+
const mockFetch = vi.mocked(fetch);
|
|
492
|
+
mockFetch.mockResolvedValueOnce({
|
|
493
|
+
ok: true,
|
|
494
|
+
status: 200,
|
|
495
|
+
json: async () => ({ traceId: "wrapper-trace-str" }),
|
|
496
|
+
});
|
|
497
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
498
|
+
const getString = async () => "Hello, World!";
|
|
499
|
+
const wrappedFn = client.withSpan("string-service", getString);
|
|
500
|
+
const result = await wrappedFn();
|
|
501
|
+
expect(result).toBe("Hello, World!");
|
|
502
|
+
// Wait for trace
|
|
503
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
504
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
505
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
506
|
+
// Result is in externalSpan.span_data.output
|
|
507
|
+
expect(traceBody.rawSpan.span_data.output).toBe("Hello, World!");
|
|
508
|
+
});
|
|
509
|
+
it("should handle functions with no arguments", async () => {
|
|
510
|
+
const mockFetch = vi.mocked(fetch);
|
|
511
|
+
mockFetch.mockResolvedValueOnce({
|
|
512
|
+
ok: true,
|
|
513
|
+
status: 200,
|
|
514
|
+
json: async () => ({ traceId: "wrapper-trace-noargs" }),
|
|
515
|
+
});
|
|
516
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
517
|
+
const getTimestamp = async () => Date.now();
|
|
518
|
+
const wrappedFn = client.withSpan("timestamp-service", getTimestamp);
|
|
519
|
+
const result = await wrappedFn();
|
|
520
|
+
expect(typeof result).toBe("number");
|
|
521
|
+
// Wait for trace
|
|
522
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
523
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
524
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
525
|
+
// Empty array for no-arg functions
|
|
526
|
+
expect(traceBody.rawSpan.span_data.input).toEqual([]);
|
|
527
|
+
});
|
|
528
|
+
it("should include superjson meta for special types like Date", async () => {
|
|
529
|
+
const mockFetch = vi.mocked(fetch);
|
|
530
|
+
mockFetch.mockResolvedValueOnce({
|
|
531
|
+
ok: true,
|
|
532
|
+
status: 200,
|
|
533
|
+
json: async () => ({ traceId: "wrapper-trace-date" }),
|
|
534
|
+
});
|
|
535
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
536
|
+
const testDate = new Date("2024-01-15T10:30:00.000Z");
|
|
537
|
+
const getDateResult = async (inputDate) => ({
|
|
538
|
+
timestamp: inputDate,
|
|
539
|
+
formatted: inputDate.toISOString(),
|
|
540
|
+
});
|
|
541
|
+
const wrappedFn = client.withSpan("date-service", getDateResult);
|
|
542
|
+
const result = await wrappedFn(testDate);
|
|
543
|
+
expect(result.timestamp).toEqual(testDate);
|
|
544
|
+
// Wait for trace
|
|
545
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
546
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
547
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
548
|
+
// Result should have JSON data and meta for Date type
|
|
549
|
+
expect(traceBody.rawSpan.span_data.output.timestamp).toBe("2024-01-15T10:30:00.000Z");
|
|
550
|
+
expect(traceBody.rawSpan.span_data.output.formatted).toBe("2024-01-15T10:30:00.000Z");
|
|
551
|
+
expect(traceBody.rawSpan.span_data.output_meta).toBeDefined();
|
|
552
|
+
expect(traceBody.rawSpan.span_data.output_meta.values.timestamp).toEqual([
|
|
553
|
+
"Date",
|
|
554
|
+
]);
|
|
555
|
+
// Inputs should also have meta for the Date argument (array format)
|
|
556
|
+
expect(traceBody.rawSpan.span_data.input[0]).toBe("2024-01-15T10:30:00.000Z");
|
|
557
|
+
expect(traceBody.rawSpan.span_data.input_meta).toBeDefined();
|
|
558
|
+
expect(traceBody.rawSpan.span_data.input_meta.values["0"]).toEqual([
|
|
559
|
+
"Date",
|
|
560
|
+
]);
|
|
561
|
+
});
|
|
562
|
+
it("should not throw if trace creation fails", async () => {
|
|
563
|
+
const mockFetch = vi.mocked(fetch);
|
|
564
|
+
const consoleErrorSpy = vi
|
|
565
|
+
.spyOn(console, "error")
|
|
566
|
+
.mockImplementation(() => { });
|
|
567
|
+
// Mock trace creation to fail
|
|
568
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
569
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
570
|
+
const originalFn = async () => "success";
|
|
571
|
+
const wrappedFn = client.withSpan("failing-trace", originalFn);
|
|
572
|
+
// Function should still return normally
|
|
573
|
+
const result = await wrappedFn();
|
|
574
|
+
expect(result).toBe("success");
|
|
575
|
+
// Allow microtasks to run so the promise rejection can be caught
|
|
576
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
577
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
578
|
+
// Wait for console.error to be called - this ensures the .catch() handler has run
|
|
579
|
+
await vi.waitFor(() => {
|
|
580
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create external span");
|
|
581
|
+
}, { timeout: 1000 });
|
|
582
|
+
consoleErrorSpy.mockRestore();
|
|
583
|
+
});
|
|
584
|
+
it("should share trace ID for nested withSpan calls", async () => {
|
|
585
|
+
const mockFetch = vi.mocked(fetch);
|
|
586
|
+
// Mock responses for both spans
|
|
587
|
+
mockFetch.mockResolvedValue({
|
|
588
|
+
ok: true,
|
|
589
|
+
status: 200,
|
|
590
|
+
json: async () => ({ traceId: "shared-trace" }),
|
|
591
|
+
});
|
|
592
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
593
|
+
const innerFn = async () => "inner result";
|
|
594
|
+
const outerFn = async () => {
|
|
595
|
+
const wrappedInner = client.withSpan("inner-span", innerFn);
|
|
596
|
+
return wrappedInner();
|
|
597
|
+
};
|
|
598
|
+
const wrappedOuter = client.withSpan("outer-span", outerFn);
|
|
599
|
+
await wrappedOuter();
|
|
600
|
+
// Wait for background traces
|
|
601
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
602
|
+
// Get all span creation calls
|
|
603
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
604
|
+
expect(spanCalls.length).toBe(2);
|
|
605
|
+
const spanBodies = spanCalls.map((call) => JSON.parse(call[1].body));
|
|
606
|
+
// Both spans should share the same trace ID
|
|
607
|
+
const traceId = spanBodies[0].rawSpan.trace_id;
|
|
608
|
+
expect(spanBodies[1].rawSpan.trace_id).toBe(traceId);
|
|
609
|
+
});
|
|
610
|
+
it("should include parent_id for child spans", async () => {
|
|
611
|
+
const mockFetch = vi.mocked(fetch);
|
|
612
|
+
mockFetch.mockResolvedValue({
|
|
613
|
+
ok: true,
|
|
614
|
+
status: 200,
|
|
615
|
+
json: async () => ({ traceId: "nested-trace" }),
|
|
616
|
+
});
|
|
617
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
618
|
+
const innerFn = async () => "inner";
|
|
619
|
+
const outerFn = async () => {
|
|
620
|
+
const wrappedInner = client.withSpan("inner-span", innerFn);
|
|
621
|
+
return wrappedInner();
|
|
622
|
+
};
|
|
623
|
+
const wrappedOuter = client.withSpan("outer-span", outerFn);
|
|
624
|
+
await wrappedOuter();
|
|
625
|
+
// Wait for background traces
|
|
626
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
627
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
628
|
+
const spanBodies = spanCalls.map((call) => JSON.parse(call[1].body));
|
|
629
|
+
// Find the inner and outer spans
|
|
630
|
+
const innerSpan = spanBodies.find((b) => b.traceFunctionKey === "inner-span");
|
|
631
|
+
const outerSpan = spanBodies.find((b) => b.traceFunctionKey === "outer-span");
|
|
632
|
+
// Inner span should have parent_id pointing to outer span
|
|
633
|
+
expect(innerSpan.rawSpan.parent_id).toBe(outerSpan.rawSpan.id);
|
|
634
|
+
// Outer span (root) should not have parent_id
|
|
635
|
+
expect(outerSpan.rawSpan.parent_id).toBeUndefined();
|
|
636
|
+
});
|
|
637
|
+
it("should generate new trace ID for independent calls", async () => {
|
|
638
|
+
const mockFetch = vi.mocked(fetch);
|
|
639
|
+
mockFetch.mockResolvedValue({
|
|
640
|
+
ok: true,
|
|
641
|
+
status: 200,
|
|
642
|
+
json: async () => ({ traceId: "independent-trace" }),
|
|
643
|
+
});
|
|
644
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
645
|
+
const fn1 = async () => "result1";
|
|
646
|
+
const fn2 = async () => "result2";
|
|
647
|
+
const wrapped1 = client.withSpan("span-1", fn1);
|
|
648
|
+
const wrapped2 = client.withSpan("span-2", fn2);
|
|
649
|
+
await wrapped1();
|
|
650
|
+
await wrapped2();
|
|
651
|
+
// Wait for background traces
|
|
652
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
653
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
654
|
+
const spanBodies = spanCalls.map((call) => JSON.parse(call[1].body));
|
|
655
|
+
// Each independent call should have a different trace ID
|
|
656
|
+
expect(spanBodies[0].rawSpan.trace_id).not.toBe(spanBodies[1].rawSpan.trace_id);
|
|
657
|
+
});
|
|
658
|
+
it("should include function name in span data", async () => {
|
|
659
|
+
const mockFetch = vi.mocked(fetch);
|
|
660
|
+
mockFetch.mockResolvedValueOnce({
|
|
661
|
+
ok: true,
|
|
662
|
+
status: 200,
|
|
663
|
+
json: async () => ({ traceId: "func-name-trace" }),
|
|
664
|
+
});
|
|
665
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
666
|
+
async function myNamedFunction(x) {
|
|
667
|
+
return x * 2;
|
|
668
|
+
}
|
|
669
|
+
const wrappedFn = client.withSpan("math-service", myNamedFunction);
|
|
670
|
+
await wrappedFn(5);
|
|
671
|
+
// Wait for background trace
|
|
672
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
673
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
674
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
675
|
+
expect(traceBody.rawSpan.span_data.function_name).toBe("myNamedFunction");
|
|
676
|
+
});
|
|
677
|
+
it("should handle anonymous functions without function name", async () => {
|
|
678
|
+
const mockFetch = vi.mocked(fetch);
|
|
679
|
+
mockFetch.mockResolvedValueOnce({
|
|
680
|
+
ok: true,
|
|
681
|
+
status: 200,
|
|
682
|
+
json: async () => ({ traceId: "anon-func-trace" }),
|
|
683
|
+
});
|
|
684
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
685
|
+
const wrappedFn = client.withSpan("anon-service", async (x) => x * 2);
|
|
686
|
+
await wrappedFn(5);
|
|
687
|
+
// Wait for background trace
|
|
688
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
689
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
690
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
691
|
+
// Anonymous arrow functions don't have a name, so function_name should be undefined
|
|
692
|
+
expect(traceBody.rawSpan.span_data.function_name).toBeUndefined();
|
|
693
|
+
});
|
|
694
|
+
it("should use function name as span name when function has a name", async () => {
|
|
695
|
+
const mockFetch = vi.mocked(fetch);
|
|
696
|
+
mockFetch.mockResolvedValueOnce({
|
|
697
|
+
ok: true,
|
|
698
|
+
status: 200,
|
|
699
|
+
json: async () => ({ traceId: "named-span-trace" }),
|
|
700
|
+
});
|
|
701
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
702
|
+
async function processOrder(id) {
|
|
703
|
+
return `processed-${id}`;
|
|
704
|
+
}
|
|
705
|
+
const wrappedFn = client.withSpan("order-service", processOrder);
|
|
706
|
+
await wrappedFn("123");
|
|
707
|
+
// Wait for background trace
|
|
708
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
709
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
710
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
711
|
+
// Span name should be the function name, not the trace function key
|
|
712
|
+
expect(traceBody.rawSpan.span_data.name).toBe("processOrder");
|
|
713
|
+
expect(traceBody.rawSpan.span_data.function_name).toBe("processOrder");
|
|
714
|
+
});
|
|
715
|
+
it("should fall back to trace function key as span name for anonymous functions", async () => {
|
|
716
|
+
const mockFetch = vi.mocked(fetch);
|
|
717
|
+
mockFetch.mockResolvedValueOnce({
|
|
718
|
+
ok: true,
|
|
719
|
+
status: 200,
|
|
720
|
+
json: async () => ({ traceId: "anon-span-trace" }),
|
|
721
|
+
});
|
|
722
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
723
|
+
const wrappedFn = client.withSpan("anonymous-service", async (x) => x * 2);
|
|
724
|
+
await wrappedFn(5);
|
|
725
|
+
// Wait for background trace
|
|
726
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
727
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
728
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
729
|
+
// Span name should fall back to trace function key for anonymous functions
|
|
730
|
+
expect(traceBody.rawSpan.span_data.name).toBe("anonymous-service");
|
|
731
|
+
expect(traceBody.rawSpan.span_data.function_name).toBeUndefined();
|
|
732
|
+
});
|
|
733
|
+
it("should use explicit name option over function name", async () => {
|
|
734
|
+
const mockFetch = vi.mocked(fetch);
|
|
735
|
+
mockFetch.mockResolvedValueOnce({
|
|
736
|
+
ok: true,
|
|
737
|
+
status: 200,
|
|
738
|
+
json: async () => ({ traceId: "explicit-name-trace" }),
|
|
739
|
+
});
|
|
740
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
741
|
+
async function myFunction(id) {
|
|
742
|
+
return `result-${id}`;
|
|
743
|
+
}
|
|
744
|
+
const wrappedFn = client.withSpan("my-service", { name: "CustomSpanName" }, myFunction);
|
|
745
|
+
await wrappedFn("123");
|
|
746
|
+
// Wait for background trace
|
|
747
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
748
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
749
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
750
|
+
// Span name should be the explicit name, not the function name
|
|
751
|
+
expect(traceBody.rawSpan.span_data.name).toBe("CustomSpanName");
|
|
752
|
+
// function_name should still be the actual function name
|
|
753
|
+
expect(traceBody.rawSpan.span_data.function_name).toBe("myFunction");
|
|
754
|
+
});
|
|
755
|
+
it("should use explicit name option for anonymous functions", async () => {
|
|
756
|
+
const mockFetch = vi.mocked(fetch);
|
|
757
|
+
mockFetch.mockResolvedValueOnce({
|
|
758
|
+
ok: true,
|
|
759
|
+
status: 200,
|
|
760
|
+
json: async () => ({ traceId: "anon-explicit-name-trace" }),
|
|
761
|
+
});
|
|
762
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
763
|
+
const wrappedFn = client.withSpan("anon-service", { name: "AnonymousHandler" }, async (x) => x * 2);
|
|
764
|
+
await wrappedFn(5);
|
|
765
|
+
// Wait for background trace
|
|
766
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
767
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
768
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
769
|
+
// Span name should be the explicit name
|
|
770
|
+
expect(traceBody.rawSpan.span_data.name).toBe("AnonymousHandler");
|
|
771
|
+
// No function_name for anonymous functions
|
|
772
|
+
expect(traceBody.rawSpan.span_data.function_name).toBeUndefined();
|
|
773
|
+
});
|
|
774
|
+
it("should still send span on error", async () => {
|
|
775
|
+
const mockFetch = vi.mocked(fetch);
|
|
776
|
+
mockFetch.mockResolvedValue({
|
|
777
|
+
ok: true,
|
|
778
|
+
status: 200,
|
|
779
|
+
json: async () => ({ traceId: "error-trace" }),
|
|
780
|
+
});
|
|
781
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
782
|
+
const failingFn = async () => {
|
|
783
|
+
throw new Error("Test error");
|
|
784
|
+
};
|
|
785
|
+
const wrappedFn = client.withSpan("failing-fn", failingFn);
|
|
786
|
+
await expect(wrappedFn()).rejects.toThrow("Test error");
|
|
787
|
+
// Wait for background traces
|
|
788
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
789
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
790
|
+
expect(spanCalls.length).toBe(1);
|
|
791
|
+
const spanBody = JSON.parse(spanCalls[0][1].body);
|
|
792
|
+
expect(spanBody.rawSpan.span_data.error).toBe("Test error");
|
|
793
|
+
});
|
|
794
|
+
it("should default type to 'custom' when no options provided", async () => {
|
|
795
|
+
const mockFetch = vi.mocked(fetch);
|
|
796
|
+
mockFetch.mockResolvedValueOnce({
|
|
797
|
+
ok: true,
|
|
798
|
+
status: 200,
|
|
799
|
+
json: async () => ({ traceId: "default-type-trace" }),
|
|
800
|
+
});
|
|
801
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
802
|
+
const fn = async () => "result";
|
|
803
|
+
const wrappedFn = client.withSpan("test-service", fn);
|
|
804
|
+
await wrappedFn();
|
|
805
|
+
// Wait for background trace
|
|
806
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
807
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
808
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
809
|
+
expect(traceBody.rawSpan.span_data.type).toBe("custom");
|
|
810
|
+
});
|
|
811
|
+
it("should use provided type from options", async () => {
|
|
812
|
+
const mockFetch = vi.mocked(fetch);
|
|
813
|
+
mockFetch.mockResolvedValueOnce({
|
|
814
|
+
ok: true,
|
|
815
|
+
status: 200,
|
|
816
|
+
json: async () => ({ traceId: "function-type-trace" }),
|
|
817
|
+
});
|
|
818
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
819
|
+
const fn = async () => "result";
|
|
820
|
+
const wrappedFn = client.withSpan("tool-service", { type: "function" }, fn);
|
|
821
|
+
await wrappedFn();
|
|
822
|
+
// Wait for background trace
|
|
823
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
824
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
825
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
826
|
+
expect(traceBody.rawSpan.span_data.type).toBe("function");
|
|
827
|
+
});
|
|
828
|
+
it("should support all span types", async () => {
|
|
829
|
+
const mockFetch = vi.mocked(fetch);
|
|
830
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
831
|
+
const spanTypes = [
|
|
832
|
+
"llm",
|
|
833
|
+
"agent",
|
|
834
|
+
"function",
|
|
835
|
+
"guardrail",
|
|
836
|
+
"handoff",
|
|
837
|
+
"custom",
|
|
838
|
+
];
|
|
839
|
+
for (const spanType of spanTypes) {
|
|
840
|
+
mockFetch.mockClear();
|
|
841
|
+
mockFetch.mockResolvedValueOnce({
|
|
842
|
+
ok: true,
|
|
843
|
+
status: 200,
|
|
844
|
+
json: async () => ({ traceId: `${spanType}-trace` }),
|
|
845
|
+
});
|
|
846
|
+
const fn = async () => spanType;
|
|
847
|
+
const wrappedFn = client.withSpan(`${spanType}-service`, { type: spanType }, fn);
|
|
848
|
+
await wrappedFn();
|
|
849
|
+
// Wait for background trace
|
|
850
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
851
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
852
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
853
|
+
expect(traceBody.rawSpan.span_data.type).toBe(spanType);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
it("should work with type option and sync functions", async () => {
|
|
857
|
+
const mockFetch = vi.mocked(fetch);
|
|
858
|
+
mockFetch.mockResolvedValueOnce({
|
|
859
|
+
ok: true,
|
|
860
|
+
status: 200,
|
|
861
|
+
json: async () => ({ traceId: "sync-type-trace" }),
|
|
862
|
+
});
|
|
863
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
864
|
+
const fn = (a, b) => a + b;
|
|
865
|
+
const wrappedFn = client.withSpan("calculator", { type: "function" }, fn);
|
|
866
|
+
const result = wrappedFn(5, 3);
|
|
867
|
+
expect(result).toBe(8);
|
|
868
|
+
// Wait for background trace
|
|
869
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
870
|
+
const traceCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
871
|
+
const traceBody = JSON.parse(traceCall[1].body);
|
|
872
|
+
expect(traceBody.rawSpan.span_data.type).toBe("function");
|
|
873
|
+
expect(traceBody.rawSpan.span_data.input).toEqual([5, 3]);
|
|
874
|
+
expect(traceBody.rawSpan.span_data.output).toBe(8);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
describe("enabled option", () => {
|
|
878
|
+
it("should default enabled to true", () => {
|
|
879
|
+
const mockFetch = vi.mocked(fetch);
|
|
880
|
+
mockFetch.mockResolvedValueOnce({
|
|
881
|
+
ok: true,
|
|
882
|
+
status: 200,
|
|
883
|
+
json: async () => ({ traceId: "enabled-default-trace" }),
|
|
884
|
+
});
|
|
885
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
886
|
+
const fn = async () => "result";
|
|
887
|
+
const wrappedFn = client.withSpan("test-service", fn);
|
|
888
|
+
// If enabled defaults to true, the wrapped function should send spans
|
|
889
|
+
// We can verify by checking that it's not the same reference as the original
|
|
890
|
+
expect(wrappedFn).not.toBe(fn);
|
|
891
|
+
});
|
|
892
|
+
it("should return unwrapped function when enabled is false", () => {
|
|
893
|
+
const client = new Simforge({ apiKey: "test-key", enabled: false });
|
|
894
|
+
const fn = async () => "result";
|
|
895
|
+
const wrappedFn = client.withSpan("test-service", fn);
|
|
896
|
+
// When disabled, withSpan should return the original function
|
|
897
|
+
expect(wrappedFn).toBe(fn);
|
|
898
|
+
});
|
|
899
|
+
it("should not send spans when enabled is false", async () => {
|
|
900
|
+
const mockFetch = vi.mocked(fetch);
|
|
901
|
+
mockFetch.mockClear();
|
|
902
|
+
const client = new Simforge({ apiKey: "test-key", enabled: false });
|
|
903
|
+
const fn = async (x) => x * 2;
|
|
904
|
+
const wrappedFn = client.withSpan("test-service", fn);
|
|
905
|
+
const result = await wrappedFn(5);
|
|
906
|
+
expect(result).toBe(10);
|
|
907
|
+
// Wait for any potential background traces
|
|
908
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
909
|
+
// No fetch calls should have been made
|
|
910
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
911
|
+
expect(spanCalls.length).toBe(0);
|
|
912
|
+
});
|
|
913
|
+
it("should return unwrapped function via getFunction when enabled is false", () => {
|
|
914
|
+
const client = new Simforge({ apiKey: "test-key", enabled: false });
|
|
915
|
+
const func = client.getFunction("my-function");
|
|
916
|
+
const fn = async () => "result";
|
|
917
|
+
const wrappedFn = func.withSpan(fn);
|
|
918
|
+
// When disabled, should return the original function
|
|
919
|
+
expect(wrappedFn).toBe(fn);
|
|
920
|
+
});
|
|
921
|
+
it("should return unwrapped function with options when enabled is false", () => {
|
|
922
|
+
const client = new Simforge({ apiKey: "test-key", enabled: false });
|
|
923
|
+
const fn = async () => "result";
|
|
924
|
+
const wrappedFn = client.withSpan("test-service", { type: "function" }, fn);
|
|
925
|
+
expect(wrappedFn).toBe(fn);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
describe("metadata", () => {
|
|
929
|
+
it("metadata is included in span_data when provided", async () => {
|
|
930
|
+
const mockFetch = vi.mocked(fetch);
|
|
931
|
+
mockFetch.mockResolvedValue({
|
|
932
|
+
ok: true,
|
|
933
|
+
status: 200,
|
|
934
|
+
json: async () => ({ traceId: "metadata-trace" }),
|
|
935
|
+
});
|
|
936
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
937
|
+
async function processOrder(id) {
|
|
938
|
+
return { id };
|
|
939
|
+
}
|
|
940
|
+
const wrapped = client.withSpan("my-service", {
|
|
941
|
+
type: "function",
|
|
942
|
+
metadata: { user_id: "u-123", region: "us-east" },
|
|
943
|
+
}, processOrder);
|
|
944
|
+
await wrapped("123");
|
|
945
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
946
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
947
|
+
expect(spanCall).toBeDefined();
|
|
948
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
949
|
+
const spanData = traceBody.rawSpan
|
|
950
|
+
.span_data;
|
|
951
|
+
expect(spanData.metadata).toEqual({ user_id: "u-123", region: "us-east" });
|
|
952
|
+
});
|
|
953
|
+
it("metadata is omitted from span_data when not provided", async () => {
|
|
954
|
+
const mockFetch = vi.mocked(fetch);
|
|
955
|
+
mockFetch.mockResolvedValue({
|
|
956
|
+
ok: true,
|
|
957
|
+
status: 200,
|
|
958
|
+
json: async () => ({ traceId: "no-metadata-trace" }),
|
|
959
|
+
});
|
|
960
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
961
|
+
async function myFn() {
|
|
962
|
+
return "result";
|
|
963
|
+
}
|
|
964
|
+
const wrapped = client.withSpan("my-service", myFn);
|
|
965
|
+
await wrapped();
|
|
966
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
967
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
968
|
+
expect(spanCall).toBeDefined();
|
|
969
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
970
|
+
const spanData = traceBody.rawSpan
|
|
971
|
+
.span_data;
|
|
972
|
+
expect(spanData.metadata).toBeUndefined();
|
|
973
|
+
});
|
|
974
|
+
it("metadata works with getFunction fluent API", async () => {
|
|
975
|
+
const mockFetch = vi.mocked(fetch);
|
|
976
|
+
mockFetch.mockResolvedValue({
|
|
977
|
+
ok: true,
|
|
978
|
+
status: 200,
|
|
979
|
+
json: async () => ({ traceId: "fluent-metadata-trace" }),
|
|
980
|
+
});
|
|
981
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
982
|
+
const service = client.getFunction("order-processing");
|
|
983
|
+
async function processOrder(id) {
|
|
984
|
+
return { id };
|
|
985
|
+
}
|
|
986
|
+
const wrapped = service.withSpan({
|
|
987
|
+
type: "function",
|
|
988
|
+
metadata: { env: "staging", version: "1.2.3" },
|
|
989
|
+
}, processOrder);
|
|
990
|
+
await wrapped("123");
|
|
991
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
992
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
993
|
+
expect(spanCall).toBeDefined();
|
|
994
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
995
|
+
const spanData = traceBody.rawSpan
|
|
996
|
+
.span_data;
|
|
997
|
+
expect(spanData.metadata).toEqual({ env: "staging", version: "1.2.3" });
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
describe("runtime metadata (getCurrentSpan)", () => {
|
|
1001
|
+
it("sets metadata at runtime from inside a span", async () => {
|
|
1002
|
+
const mockFetch = vi.mocked(fetch);
|
|
1003
|
+
mockFetch.mockResolvedValue({
|
|
1004
|
+
ok: true,
|
|
1005
|
+
status: 200,
|
|
1006
|
+
json: async () => ({ traceId: "runtime-meta-trace" }),
|
|
1007
|
+
});
|
|
1008
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1009
|
+
async function processOrder(id) {
|
|
1010
|
+
getCurrentSpan()?.setMetadata({
|
|
1011
|
+
request_id: "req-789",
|
|
1012
|
+
user_id: "u-456",
|
|
1013
|
+
});
|
|
1014
|
+
return { id };
|
|
1015
|
+
}
|
|
1016
|
+
const wrapped = client.withSpan("my-service", { type: "function" }, processOrder);
|
|
1017
|
+
await wrapped("123");
|
|
1018
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1019
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
1020
|
+
expect(spanCall).toBeDefined();
|
|
1021
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
1022
|
+
const spanData = traceBody.rawSpan
|
|
1023
|
+
.span_data;
|
|
1024
|
+
expect(spanData.metadata).toEqual({
|
|
1025
|
+
request_id: "req-789",
|
|
1026
|
+
user_id: "u-456",
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
it("merges runtime metadata with definition-time metadata (runtime wins)", async () => {
|
|
1030
|
+
const mockFetch = vi.mocked(fetch);
|
|
1031
|
+
mockFetch.mockResolvedValue({
|
|
1032
|
+
ok: true,
|
|
1033
|
+
status: 200,
|
|
1034
|
+
json: async () => ({ traceId: "merge-meta-trace" }),
|
|
1035
|
+
});
|
|
1036
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1037
|
+
async function processOrder(id) {
|
|
1038
|
+
getCurrentSpan()?.setMetadata({
|
|
1039
|
+
region: "eu-west",
|
|
1040
|
+
request_id: "req-789",
|
|
1041
|
+
});
|
|
1042
|
+
return { id };
|
|
1043
|
+
}
|
|
1044
|
+
const wrapped = client.withSpan("my-service", { type: "function", metadata: { user_id: "u-123", region: "us-east" } }, processOrder);
|
|
1045
|
+
await wrapped("123");
|
|
1046
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1047
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
1048
|
+
expect(spanCall).toBeDefined();
|
|
1049
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
1050
|
+
const spanData = traceBody.rawSpan
|
|
1051
|
+
.span_data;
|
|
1052
|
+
expect(spanData.metadata).toEqual({
|
|
1053
|
+
user_id: "u-123",
|
|
1054
|
+
region: "eu-west",
|
|
1055
|
+
request_id: "req-789",
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
it("getCurrentSpan returns null outside of a span", () => {
|
|
1059
|
+
expect(getCurrentSpan()).toBeNull();
|
|
1060
|
+
});
|
|
1061
|
+
it("works with getFunction fluent API", async () => {
|
|
1062
|
+
const mockFetch = vi.mocked(fetch);
|
|
1063
|
+
mockFetch.mockResolvedValue({
|
|
1064
|
+
ok: true,
|
|
1065
|
+
status: 200,
|
|
1066
|
+
json: async () => ({ traceId: "fluent-runtime-meta" }),
|
|
1067
|
+
});
|
|
1068
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1069
|
+
const service = client.getFunction("order-processing");
|
|
1070
|
+
async function processOrder(id) {
|
|
1071
|
+
getCurrentSpan()?.setMetadata({ computed_at: "2024-01-01" });
|
|
1072
|
+
return { id };
|
|
1073
|
+
}
|
|
1074
|
+
const wrapped = service.withSpan({ type: "function" }, processOrder);
|
|
1075
|
+
await wrapped("123");
|
|
1076
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1077
|
+
const spanCall = mockFetch.mock.calls.find((c) => String(c[0]).includes("/sdk/externalSpans"));
|
|
1078
|
+
expect(spanCall).toBeDefined();
|
|
1079
|
+
const traceBody = JSON.parse(spanCall[1]?.body ?? "{}");
|
|
1080
|
+
const spanData = traceBody.rawSpan
|
|
1081
|
+
.span_data;
|
|
1082
|
+
expect(spanData.metadata).toEqual({ computed_at: "2024-01-01" });
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
describe("error resilience", () => {
|
|
1086
|
+
it("should not crash the host app when span sending throws synchronously", async () => {
|
|
1087
|
+
const mockFetch = vi.mocked(fetch);
|
|
1088
|
+
// Make fetch throw synchronously (simulating serialization failure)
|
|
1089
|
+
mockFetch.mockImplementation(() => {
|
|
1090
|
+
throw new Error("Serialization error");
|
|
1091
|
+
});
|
|
1092
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1093
|
+
const fn = async (x) => x * 2;
|
|
1094
|
+
const wrappedFn = client.withSpan("resilient-service", fn);
|
|
1095
|
+
// Function should still return normally
|
|
1096
|
+
const result = await wrappedFn(5);
|
|
1097
|
+
expect(result).toBe(10);
|
|
1098
|
+
});
|
|
1099
|
+
it("should propagate user errors even when SDK has internal errors", async () => {
|
|
1100
|
+
const mockFetch = vi.mocked(fetch);
|
|
1101
|
+
mockFetch.mockImplementation(() => {
|
|
1102
|
+
throw new Error("SDK internal error");
|
|
1103
|
+
});
|
|
1104
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1105
|
+
const failingFn = async () => {
|
|
1106
|
+
throw new Error("User error");
|
|
1107
|
+
};
|
|
1108
|
+
const wrappedFn = client.withSpan("error-service", failingFn);
|
|
1109
|
+
await expect(wrappedFn()).rejects.toThrow("User error");
|
|
1110
|
+
});
|
|
1111
|
+
it("should not crash with sync functions when span sending throws", () => {
|
|
1112
|
+
const mockFetch = vi.mocked(fetch);
|
|
1113
|
+
mockFetch.mockImplementation(() => {
|
|
1114
|
+
throw new Error("Serialization error");
|
|
1115
|
+
});
|
|
1116
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1117
|
+
const fn = (a, b) => a + b;
|
|
1118
|
+
const wrappedFn = client.withSpan("sync-resilient", fn);
|
|
1119
|
+
const result = wrappedFn(5, 3);
|
|
1120
|
+
expect(result).toBe(8);
|
|
1121
|
+
});
|
|
1122
|
+
it("should propagate user errors from sync functions even when SDK has internal errors", () => {
|
|
1123
|
+
const mockFetch = vi.mocked(fetch);
|
|
1124
|
+
mockFetch.mockImplementation(() => {
|
|
1125
|
+
throw new Error("SDK internal error");
|
|
1126
|
+
});
|
|
1127
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1128
|
+
const failingFn = () => {
|
|
1129
|
+
throw new Error("Sync user error");
|
|
1130
|
+
};
|
|
1131
|
+
const wrappedFn = client.withSpan("sync-error-service", failingFn);
|
|
1132
|
+
expect(() => wrappedFn()).toThrow("Sync user error");
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
describe("getFunction", () => {
|
|
1136
|
+
it("should return a SimforgeFunction instance", () => {
|
|
1137
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1138
|
+
const func = client.getFunction("my-function");
|
|
1139
|
+
expect(func).toBeDefined();
|
|
1140
|
+
expect(typeof func.withSpan).toBe("function");
|
|
1141
|
+
});
|
|
1142
|
+
it("should wrap functions with the bound traceFunctionKey", async () => {
|
|
1143
|
+
const mockFetch = vi.mocked(fetch);
|
|
1144
|
+
mockFetch.mockResolvedValueOnce({
|
|
1145
|
+
ok: true,
|
|
1146
|
+
status: 200,
|
|
1147
|
+
json: async () => ({ traceId: "getfunc-trace" }),
|
|
1148
|
+
});
|
|
1149
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1150
|
+
const func = client.getFunction("bound-key");
|
|
1151
|
+
const originalFn = async (x) => x * 2;
|
|
1152
|
+
const wrappedFn = func.withSpan(originalFn);
|
|
1153
|
+
const result = await wrappedFn(5);
|
|
1154
|
+
expect(result).toBe(10);
|
|
1155
|
+
// Wait for background trace
|
|
1156
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1157
|
+
const spanCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
1158
|
+
const spanBody = JSON.parse(spanCall[1].body);
|
|
1159
|
+
expect(spanBody.traceFunctionKey).toBe("bound-key");
|
|
1160
|
+
});
|
|
1161
|
+
it("should allow wrapping multiple functions with the same key", async () => {
|
|
1162
|
+
const mockFetch = vi.mocked(fetch);
|
|
1163
|
+
mockFetch.mockResolvedValue({
|
|
1164
|
+
ok: true,
|
|
1165
|
+
status: 200,
|
|
1166
|
+
json: async () => ({ traceId: "multi-trace" }),
|
|
1167
|
+
});
|
|
1168
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1169
|
+
const func = client.getFunction("shared-key");
|
|
1170
|
+
const fn1 = async () => "one";
|
|
1171
|
+
const fn2 = async () => "two";
|
|
1172
|
+
const wrapped1 = func.withSpan(fn1);
|
|
1173
|
+
const wrapped2 = func.withSpan(fn2);
|
|
1174
|
+
await wrapped1();
|
|
1175
|
+
await wrapped2();
|
|
1176
|
+
// Wait for background traces
|
|
1177
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1178
|
+
const spanCalls = mockFetch.mock.calls.filter((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
1179
|
+
const spanBodies = spanCalls.map((call) => JSON.parse(call[1].body));
|
|
1180
|
+
// Both should have the same traceFunctionKey
|
|
1181
|
+
expect(spanBodies[0].traceFunctionKey).toBe("shared-key");
|
|
1182
|
+
expect(spanBodies[1].traceFunctionKey).toBe("shared-key");
|
|
1183
|
+
});
|
|
1184
|
+
it("should default type to 'custom' when no options provided via getFunction", async () => {
|
|
1185
|
+
const mockFetch = vi.mocked(fetch);
|
|
1186
|
+
mockFetch.mockResolvedValueOnce({
|
|
1187
|
+
ok: true,
|
|
1188
|
+
status: 200,
|
|
1189
|
+
json: async () => ({ traceId: "func-default-type" }),
|
|
1190
|
+
});
|
|
1191
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1192
|
+
const func = client.getFunction("default-type-key");
|
|
1193
|
+
const originalFn = async () => "result";
|
|
1194
|
+
const wrappedFn = func.withSpan(originalFn);
|
|
1195
|
+
await wrappedFn();
|
|
1196
|
+
// Wait for background trace
|
|
1197
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1198
|
+
const spanCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
1199
|
+
const spanBody = JSON.parse(spanCall[1].body);
|
|
1200
|
+
expect(spanBody.rawSpan.span_data.type).toBe("custom");
|
|
1201
|
+
});
|
|
1202
|
+
it("should use provided type from options via getFunction", async () => {
|
|
1203
|
+
const mockFetch = vi.mocked(fetch);
|
|
1204
|
+
mockFetch.mockResolvedValueOnce({
|
|
1205
|
+
ok: true,
|
|
1206
|
+
status: 200,
|
|
1207
|
+
json: async () => ({ traceId: "func-agent-type" }),
|
|
1208
|
+
});
|
|
1209
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1210
|
+
const func = client.getFunction("agent-key");
|
|
1211
|
+
const originalFn = async () => "agent result";
|
|
1212
|
+
const wrappedFn = func.withSpan({ type: "agent" }, originalFn);
|
|
1213
|
+
await wrappedFn();
|
|
1214
|
+
// Wait for background trace
|
|
1215
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1216
|
+
const spanCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
1217
|
+
const spanBody = JSON.parse(spanCall[1].body);
|
|
1218
|
+
expect(spanBody.rawSpan.span_data.type).toBe("agent");
|
|
1219
|
+
});
|
|
1220
|
+
it("should support guardrail type via getFunction", async () => {
|
|
1221
|
+
const mockFetch = vi.mocked(fetch);
|
|
1222
|
+
mockFetch.mockResolvedValueOnce({
|
|
1223
|
+
ok: true,
|
|
1224
|
+
status: 200,
|
|
1225
|
+
json: async () => ({ traceId: "func-guardrail-type" }),
|
|
1226
|
+
});
|
|
1227
|
+
const client = new Simforge({ apiKey: "test-key" });
|
|
1228
|
+
const func = client.getFunction("safety-check");
|
|
1229
|
+
const checkContent = async (content) => ({
|
|
1230
|
+
safe: true,
|
|
1231
|
+
content,
|
|
1232
|
+
});
|
|
1233
|
+
const wrappedFn = func.withSpan({ type: "guardrail" }, checkContent);
|
|
1234
|
+
const result = await wrappedFn("test content");
|
|
1235
|
+
expect(result).toEqual({ safe: true, content: "test content" });
|
|
1236
|
+
// Wait for background trace
|
|
1237
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1238
|
+
const spanCall = mockFetch.mock.calls.find((call) => call[0].toString().includes("/sdk/externalSpans"));
|
|
1239
|
+
const spanBody = JSON.parse(spanCall[1].body);
|
|
1240
|
+
expect(spanBody.rawSpan.span_data.type).toBe("guardrail");
|
|
1241
|
+
expect(spanBody.rawSpan.span_data.input).toEqual(["test content"]);
|
|
1242
|
+
expect(spanBody.rawSpan.span_data.output).toEqual({
|
|
1243
|
+
safe: true,
|
|
1244
|
+
content: "test content",
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
453
1248
|
});
|
|
454
1249
|
//# sourceMappingURL=client.test.js.map
|