observability-toolkit 1.8.5 → 2.0.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/README.md +126 -5
- package/dist/backends/index.d.ts +163 -0
- package/dist/backends/index.d.ts.map +1 -1
- package/dist/backends/index.js +57 -0
- package/dist/backends/index.js.map +1 -1
- package/dist/backends/index.test.js +55 -1
- package/dist/backends/index.test.js.map +1 -1
- package/dist/backends/local-jsonl.d.ts +30 -0
- package/dist/backends/local-jsonl.d.ts.map +1 -1
- package/dist/backends/local-jsonl.js +912 -550
- package/dist/backends/local-jsonl.js.map +1 -1
- package/dist/backends/signoz-api-rate-limiter.test.js +2 -1
- package/dist/backends/signoz-api-rate-limiter.test.js.map +1 -1
- package/dist/backends/signoz-api.d.ts +16 -2
- package/dist/backends/signoz-api.d.ts.map +1 -1
- package/dist/backends/signoz-api.js +650 -534
- package/dist/backends/signoz-api.js.map +1 -1
- package/dist/backends/signoz-api.test.js +6 -5
- package/dist/backends/signoz-api.test.js.map +1 -1
- package/dist/lib/agent-as-judge.d.ts +388 -0
- package/dist/lib/agent-as-judge.d.ts.map +1 -0
- package/dist/lib/agent-as-judge.js +740 -0
- package/dist/lib/agent-as-judge.js.map +1 -0
- package/dist/lib/agent-as-judge.test.d.ts +5 -0
- package/dist/lib/agent-as-judge.test.d.ts.map +1 -0
- package/dist/lib/agent-as-judge.test.js +816 -0
- package/dist/lib/agent-as-judge.test.js.map +1 -0
- package/dist/lib/cache.d.ts +15 -2
- package/dist/lib/cache.d.ts.map +1 -1
- package/dist/lib/cache.js +16 -2
- package/dist/lib/cache.js.map +1 -1
- package/dist/lib/circuit-breaker.d.ts +18 -0
- package/dist/lib/circuit-breaker.d.ts.map +1 -1
- package/dist/lib/circuit-breaker.js +41 -8
- package/dist/lib/circuit-breaker.js.map +1 -1
- package/dist/lib/confident-export.d.ts +101 -0
- package/dist/lib/confident-export.d.ts.map +1 -0
- package/dist/lib/confident-export.js +393 -0
- package/dist/lib/confident-export.js.map +1 -0
- package/dist/lib/confident-export.test.d.ts +7 -0
- package/dist/lib/confident-export.test.d.ts.map +1 -0
- package/dist/lib/confident-export.test.js +835 -0
- package/dist/lib/confident-export.test.js.map +1 -0
- package/dist/lib/constants.d.ts +75 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +104 -1
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/datadog-export.d.ts +156 -0
- package/dist/lib/datadog-export.d.ts.map +1 -0
- package/dist/lib/datadog-export.js +464 -0
- package/dist/lib/datadog-export.js.map +1 -0
- package/dist/lib/datadog-export.test.d.ts +14 -0
- package/dist/lib/datadog-export.test.d.ts.map +1 -0
- package/dist/lib/datadog-export.test.js +890 -0
- package/dist/lib/datadog-export.test.js.map +1 -0
- package/dist/lib/evaluation-hooks.d.ts +49 -0
- package/dist/lib/evaluation-hooks.d.ts.map +1 -0
- package/dist/lib/evaluation-hooks.js +488 -0
- package/dist/lib/evaluation-hooks.js.map +1 -0
- package/dist/lib/evaluation-hooks.test.d.ts +8 -0
- package/dist/lib/evaluation-hooks.test.d.ts.map +1 -0
- package/dist/lib/evaluation-hooks.test.js +624 -0
- package/dist/lib/evaluation-hooks.test.js.map +1 -0
- package/dist/lib/export-utils.d.ts +99 -0
- package/dist/lib/export-utils.d.ts.map +1 -0
- package/dist/lib/export-utils.js +238 -0
- package/dist/lib/export-utils.js.map +1 -0
- package/dist/lib/export-utils.test.d.ts +5 -0
- package/dist/lib/export-utils.test.d.ts.map +1 -0
- package/dist/lib/export-utils.test.js +193 -0
- package/dist/lib/export-utils.test.js.map +1 -0
- package/dist/lib/file-utils.d.ts +17 -2
- package/dist/lib/file-utils.d.ts.map +1 -1
- package/dist/lib/file-utils.js +24 -5
- package/dist/lib/file-utils.js.map +1 -1
- package/dist/lib/file-utils.test.js +30 -0
- package/dist/lib/file-utils.test.js.map +1 -1
- package/dist/lib/histogram.d.ts +119 -0
- package/dist/lib/histogram.d.ts.map +1 -0
- package/dist/lib/histogram.js +202 -0
- package/dist/lib/histogram.js.map +1 -0
- package/dist/lib/histogram.test.d.ts +5 -0
- package/dist/lib/histogram.test.d.ts.map +1 -0
- package/dist/lib/histogram.test.js +381 -0
- package/dist/lib/histogram.test.js.map +1 -0
- package/dist/lib/instrumentation.d.ts +153 -0
- package/dist/lib/instrumentation.d.ts.map +1 -0
- package/dist/lib/instrumentation.integration.test.d.ts +2 -0
- package/dist/lib/instrumentation.integration.test.d.ts.map +1 -0
- package/dist/lib/instrumentation.integration.test.js +589 -0
- package/dist/lib/instrumentation.integration.test.js.map +1 -0
- package/dist/lib/instrumentation.js +520 -0
- package/dist/lib/instrumentation.js.map +1 -0
- package/dist/lib/instrumentation.test.d.ts +2 -0
- package/dist/lib/instrumentation.test.d.ts.map +1 -0
- package/dist/lib/instrumentation.test.js +821 -0
- package/dist/lib/instrumentation.test.js.map +1 -0
- package/dist/lib/langfuse-export.d.ts +125 -0
- package/dist/lib/langfuse-export.d.ts.map +1 -0
- package/dist/lib/langfuse-export.js +367 -0
- package/dist/lib/langfuse-export.js.map +1 -0
- package/dist/lib/langfuse-export.test.d.ts +7 -0
- package/dist/lib/langfuse-export.test.d.ts.map +1 -0
- package/dist/lib/langfuse-export.test.js +1007 -0
- package/dist/lib/langfuse-export.test.js.map +1 -0
- package/dist/lib/llm-as-judge.d.ts +657 -0
- package/dist/lib/llm-as-judge.d.ts.map +1 -0
- package/dist/lib/llm-as-judge.js +1397 -0
- package/dist/lib/llm-as-judge.js.map +1 -0
- package/dist/lib/llm-as-judge.test.d.ts +2 -0
- package/dist/lib/llm-as-judge.test.d.ts.map +1 -0
- package/dist/lib/llm-as-judge.test.js +2409 -0
- package/dist/lib/llm-as-judge.test.js.map +1 -0
- package/dist/lib/logger.d.ts +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/metrics.d.ts +62 -0
- package/dist/lib/metrics.d.ts.map +1 -0
- package/dist/lib/metrics.js +166 -0
- package/dist/lib/metrics.js.map +1 -0
- package/dist/lib/metrics.test.d.ts +5 -0
- package/dist/lib/metrics.test.d.ts.map +1 -0
- package/dist/lib/metrics.test.js +189 -0
- package/dist/lib/metrics.test.js.map +1 -0
- package/dist/lib/parse-stats.d.ts +119 -0
- package/dist/lib/parse-stats.d.ts.map +1 -0
- package/dist/lib/parse-stats.js +206 -0
- package/dist/lib/parse-stats.js.map +1 -0
- package/dist/lib/parse-stats.test.d.ts +5 -0
- package/dist/lib/parse-stats.test.d.ts.map +1 -0
- package/dist/lib/parse-stats.test.js +283 -0
- package/dist/lib/parse-stats.test.js.map +1 -0
- package/dist/lib/phoenix-export.d.ts +109 -0
- package/dist/lib/phoenix-export.d.ts.map +1 -0
- package/dist/lib/phoenix-export.js +429 -0
- package/dist/lib/phoenix-export.js.map +1 -0
- package/dist/lib/phoenix-export.test.d.ts +11 -0
- package/dist/lib/phoenix-export.test.d.ts.map +1 -0
- package/dist/lib/phoenix-export.test.js +725 -0
- package/dist/lib/phoenix-export.test.js.map +1 -0
- package/dist/lib/server-utils.d.ts +6 -1
- package/dist/lib/server-utils.d.ts.map +1 -1
- package/dist/lib/server-utils.js +9 -1
- package/dist/lib/server-utils.js.map +1 -1
- package/dist/lib/shared-schemas.d.ts +6 -0
- package/dist/lib/shared-schemas.d.ts.map +1 -1
- package/dist/lib/shared-schemas.js +11 -4
- package/dist/lib/shared-schemas.js.map +1 -1
- package/dist/lib/verification-events.d.ts +100 -0
- package/dist/lib/verification-events.d.ts.map +1 -0
- package/dist/lib/verification-events.js +162 -0
- package/dist/lib/verification-events.js.map +1 -0
- package/dist/lib/verification-events.test.d.ts +5 -0
- package/dist/lib/verification-events.test.d.ts.map +1 -0
- package/dist/lib/verification-events.test.js +193 -0
- package/dist/lib/verification-events.test.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +77 -21
- package/dist/server.js.map +1 -1
- package/dist/tools/context-stats.d.ts.map +1 -1
- package/dist/tools/context-stats.js +6 -8
- package/dist/tools/context-stats.js.map +1 -1
- package/dist/tools/export-confident.d.ts +145 -0
- package/dist/tools/export-confident.d.ts.map +1 -0
- package/dist/tools/export-confident.js +134 -0
- package/dist/tools/export-confident.js.map +1 -0
- package/dist/tools/export-confident.test.d.ts +7 -0
- package/dist/tools/export-confident.test.d.ts.map +1 -0
- package/dist/tools/export-confident.test.js +332 -0
- package/dist/tools/export-confident.test.js.map +1 -0
- package/dist/tools/export-datadog.d.ts +160 -0
- package/dist/tools/export-datadog.d.ts.map +1 -0
- package/dist/tools/export-datadog.js +160 -0
- package/dist/tools/export-datadog.js.map +1 -0
- package/dist/tools/export-datadog.test.d.ts +8 -0
- package/dist/tools/export-datadog.test.d.ts.map +1 -0
- package/dist/tools/export-datadog.test.js +419 -0
- package/dist/tools/export-datadog.test.js.map +1 -0
- package/dist/tools/export-langfuse.d.ts +137 -0
- package/dist/tools/export-langfuse.d.ts.map +1 -0
- package/dist/tools/export-langfuse.js +131 -0
- package/dist/tools/export-langfuse.js.map +1 -0
- package/dist/tools/export-langfuse.test.d.ts +7 -0
- package/dist/tools/export-langfuse.test.d.ts.map +1 -0
- package/dist/tools/export-langfuse.test.js +303 -0
- package/dist/tools/export-langfuse.test.js.map +1 -0
- package/dist/tools/export-phoenix.d.ts +145 -0
- package/dist/tools/export-phoenix.d.ts.map +1 -0
- package/dist/tools/export-phoenix.js +135 -0
- package/dist/tools/export-phoenix.js.map +1 -0
- package/dist/tools/export-phoenix.test.d.ts +7 -0
- package/dist/tools/export-phoenix.test.d.ts.map +1 -0
- package/dist/tools/export-phoenix.test.js +316 -0
- package/dist/tools/export-phoenix.test.js.map +1 -0
- package/dist/tools/health-check.d.ts +26 -0
- package/dist/tools/health-check.d.ts.map +1 -1
- package/dist/tools/health-check.js +36 -7
- package/dist/tools/health-check.js.map +1 -1
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inject-evaluations.d.ts +1315 -0
- package/dist/tools/inject-evaluations.d.ts.map +1 -0
- package/dist/tools/inject-evaluations.js +121 -0
- package/dist/tools/inject-evaluations.js.map +1 -0
- package/dist/tools/inject-evaluations.test.d.ts +5 -0
- package/dist/tools/inject-evaluations.test.d.ts.map +1 -0
- package/dist/tools/inject-evaluations.test.js +359 -0
- package/dist/tools/inject-evaluations.test.js.map +1 -0
- package/dist/tools/query-evaluations.d.ts +25 -4
- package/dist/tools/query-evaluations.d.ts.map +1 -1
- package/dist/tools/query-evaluations.js +10 -0
- package/dist/tools/query-evaluations.js.map +1 -1
- package/dist/tools/query-llm-events.js +2 -2
- package/dist/tools/query-llm-events.js.map +1 -1
- package/dist/tools/query-logs.d.ts +8 -8
- package/dist/tools/query-logs.js +3 -3
- package/dist/tools/query-logs.js.map +1 -1
- package/dist/tools/query-metrics.d.ts +4 -4
- package/dist/tools/query-metrics.js +2 -2
- package/dist/tools/query-metrics.js.map +1 -1
- package/dist/tools/query-traces.d.ts +8 -8
- package/dist/tools/query-verifications.d.ts +111 -0
- package/dist/tools/query-verifications.d.ts.map +1 -0
- package/dist/tools/query-verifications.js +101 -0
- package/dist/tools/query-verifications.js.map +1 -0
- package/dist/tools/query-verifications.test.d.ts +5 -0
- package/dist/tools/query-verifications.test.d.ts.map +1 -0
- package/dist/tools/query-verifications.test.js +156 -0
- package/dist/tools/query-verifications.test.js.map +1 -0
- package/dist/types/evaluation-hooks.d.ts +176 -0
- package/dist/types/evaluation-hooks.d.ts.map +1 -0
- package/dist/types/evaluation-hooks.js +49 -0
- package/dist/types/evaluation-hooks.js.map +1 -0
- package/package.json +10 -2
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for phoenix-export.ts
|
|
3
|
+
*
|
|
4
|
+
* Covers config validation, auth header creation, OTLP conversion, and batching.
|
|
5
|
+
* Phoenix differs from other integrations in:
|
|
6
|
+
* - Allowing HTTP for localhost (local development)
|
|
7
|
+
* - Requiring HTTPS for Phoenix Cloud
|
|
8
|
+
* - Supporting both Bearer and legacy api_key authentication
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import { validatePhoenixUrl, validatePhoenixConfig, createPhoenixAuthHeaders, evaluationsToPhoenixTraces, exportToPhoenix, } from './phoenix-export.js';
|
|
13
|
+
import { HttpStatus } from './constants.js';
|
|
14
|
+
describe('phoenix-export', () => {
|
|
15
|
+
describe('validatePhoenixUrl', () => {
|
|
16
|
+
describe('Phoenix Cloud URLs', () => {
|
|
17
|
+
it('accepts valid Phoenix Cloud HTTPS URL', () => {
|
|
18
|
+
const url = 'https://app.phoenix.arize.com/s/my-space';
|
|
19
|
+
assert.strictEqual(validatePhoenixUrl(url), 'https://app.phoenix.arize.com/s/my-space');
|
|
20
|
+
});
|
|
21
|
+
it('rejects Phoenix Cloud HTTP URL', () => {
|
|
22
|
+
const url = 'http://app.phoenix.arize.com/s/my-space';
|
|
23
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
24
|
+
});
|
|
25
|
+
it('preserves Phoenix Cloud path', () => {
|
|
26
|
+
const url = 'https://app.phoenix.arize.com/s/workspace/project';
|
|
27
|
+
assert.strictEqual(validatePhoenixUrl(url), 'https://app.phoenix.arize.com/s/workspace/project');
|
|
28
|
+
});
|
|
29
|
+
it('strips trailing slash from Phoenix Cloud URL', () => {
|
|
30
|
+
const url = 'https://app.phoenix.arize.com/s/my-space/';
|
|
31
|
+
assert.strictEqual(validatePhoenixUrl(url), 'https://app.phoenix.arize.com/s/my-space');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('localhost URLs (local development)', () => {
|
|
35
|
+
it('accepts HTTP localhost', () => {
|
|
36
|
+
const url = 'http://localhost:6006';
|
|
37
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://localhost:6006');
|
|
38
|
+
});
|
|
39
|
+
it('accepts HTTPS localhost', () => {
|
|
40
|
+
const url = 'https://localhost:6006';
|
|
41
|
+
assert.strictEqual(validatePhoenixUrl(url), 'https://localhost:6006');
|
|
42
|
+
});
|
|
43
|
+
it('accepts HTTP 127.0.0.1', () => {
|
|
44
|
+
const url = 'http://127.0.0.1:6006';
|
|
45
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://127.0.0.1:6006');
|
|
46
|
+
});
|
|
47
|
+
it('accepts HTTP 127.x.x.x variants', () => {
|
|
48
|
+
const url = 'http://127.100.200.1:6006';
|
|
49
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://127.100.200.1:6006');
|
|
50
|
+
});
|
|
51
|
+
it('accepts HTTP [::1] IPv6 localhost', () => {
|
|
52
|
+
const url = 'http://[::1]:6006';
|
|
53
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://[::1]:6006');
|
|
54
|
+
});
|
|
55
|
+
it('accepts HTTP 0.0.0.0', () => {
|
|
56
|
+
const url = 'http://0.0.0.0:6006';
|
|
57
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://0.0.0.0:6006');
|
|
58
|
+
});
|
|
59
|
+
it('accepts HTTP .localhost TLD', () => {
|
|
60
|
+
const url = 'http://phoenix.localhost:6006';
|
|
61
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://phoenix.localhost:6006');
|
|
62
|
+
});
|
|
63
|
+
it('preserves path for localhost', () => {
|
|
64
|
+
const url = 'http://localhost:6006/api/v1';
|
|
65
|
+
assert.strictEqual(validatePhoenixUrl(url), 'http://localhost:6006/api/v1');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('self-hosted external URLs (SSRF protection)', () => {
|
|
69
|
+
it('accepts HTTPS external URL', () => {
|
|
70
|
+
const url = 'https://phoenix.example.com';
|
|
71
|
+
assert.strictEqual(validatePhoenixUrl(url), 'https://phoenix.example.com');
|
|
72
|
+
});
|
|
73
|
+
it('rejects HTTP external URL', () => {
|
|
74
|
+
const url = 'http://phoenix.example.com';
|
|
75
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
76
|
+
});
|
|
77
|
+
it('rejects private network 10.x.x.x', () => {
|
|
78
|
+
const url = 'https://10.0.0.1:6006';
|
|
79
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
80
|
+
});
|
|
81
|
+
it('rejects private network 192.168.x.x', () => {
|
|
82
|
+
const url = 'https://192.168.1.1:6006';
|
|
83
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
84
|
+
});
|
|
85
|
+
it('rejects private network 172.16-31.x.x', () => {
|
|
86
|
+
assert.strictEqual(validatePhoenixUrl('https://172.16.0.1:6006'), '');
|
|
87
|
+
assert.strictEqual(validatePhoenixUrl('https://172.31.255.255:6006'), '');
|
|
88
|
+
});
|
|
89
|
+
it('rejects cloud metadata IP', () => {
|
|
90
|
+
const url = 'https://169.254.169.254';
|
|
91
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
92
|
+
});
|
|
93
|
+
it('rejects .local TLD', () => {
|
|
94
|
+
const url = 'https://phoenix.local';
|
|
95
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
96
|
+
});
|
|
97
|
+
it('rejects .internal TLD', () => {
|
|
98
|
+
const url = 'https://phoenix.internal';
|
|
99
|
+
assert.strictEqual(validatePhoenixUrl(url), '');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('edge cases', () => {
|
|
103
|
+
it('returns empty for empty string', () => {
|
|
104
|
+
assert.strictEqual(validatePhoenixUrl(''), '');
|
|
105
|
+
});
|
|
106
|
+
it('returns empty for invalid URL', () => {
|
|
107
|
+
assert.strictEqual(validatePhoenixUrl('not-a-url'), '');
|
|
108
|
+
});
|
|
109
|
+
it('rejects file protocol', () => {
|
|
110
|
+
assert.strictEqual(validatePhoenixUrl('file:///etc/passwd'), '');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('validatePhoenixConfig', () => {
|
|
115
|
+
const originalEnv = { ...process.env };
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
delete process.env.PHOENIX_COLLECTOR_ENDPOINT;
|
|
118
|
+
delete process.env.PHOENIX_API_KEY;
|
|
119
|
+
delete process.env.PHOENIX_PROJECT_NAME;
|
|
120
|
+
delete process.env.PHOENIX_BATCH_SIZE;
|
|
121
|
+
delete process.env.PHOENIX_TIMEOUT_MS;
|
|
122
|
+
});
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
Object.assign(process.env, originalEnv);
|
|
125
|
+
});
|
|
126
|
+
it('uses default endpoint when not configured', () => {
|
|
127
|
+
const config = validatePhoenixConfig({});
|
|
128
|
+
assert.strictEqual(config.endpoint, 'http://localhost:6006');
|
|
129
|
+
});
|
|
130
|
+
it('does not require API key for localhost', () => {
|
|
131
|
+
const config = validatePhoenixConfig({
|
|
132
|
+
endpoint: 'http://localhost:6006',
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(config.apiKey, undefined);
|
|
135
|
+
});
|
|
136
|
+
it('requires API key for Phoenix Cloud', () => {
|
|
137
|
+
assert.throws(() => validatePhoenixConfig({
|
|
138
|
+
endpoint: 'https://app.phoenix.arize.com/s/my-space',
|
|
139
|
+
}), /API key not configured/);
|
|
140
|
+
});
|
|
141
|
+
it('accepts Phoenix Cloud with API key', () => {
|
|
142
|
+
const config = validatePhoenixConfig({
|
|
143
|
+
endpoint: 'https://app.phoenix.arize.com/s/my-space',
|
|
144
|
+
apiKey: 'phx_test_api_key_123',
|
|
145
|
+
});
|
|
146
|
+
assert.strictEqual(config.endpoint, 'https://app.phoenix.arize.com/s/my-space');
|
|
147
|
+
assert.strictEqual(config.apiKey, 'phx_test_api_key_123');
|
|
148
|
+
});
|
|
149
|
+
it('uses default project name', () => {
|
|
150
|
+
const config = validatePhoenixConfig({});
|
|
151
|
+
assert.strictEqual(config.projectName, 'default');
|
|
152
|
+
});
|
|
153
|
+
it('uses custom project name', () => {
|
|
154
|
+
const config = validatePhoenixConfig({
|
|
155
|
+
projectName: 'my-project',
|
|
156
|
+
});
|
|
157
|
+
assert.strictEqual(config.projectName, 'my-project');
|
|
158
|
+
});
|
|
159
|
+
it('throws for empty project name', () => {
|
|
160
|
+
assert.throws(() => validatePhoenixConfig({
|
|
161
|
+
projectName: '',
|
|
162
|
+
}), /project name cannot be empty/);
|
|
163
|
+
});
|
|
164
|
+
it('uses default batch size', () => {
|
|
165
|
+
const config = validatePhoenixConfig({});
|
|
166
|
+
assert.strictEqual(config.batchSize, 100);
|
|
167
|
+
});
|
|
168
|
+
it('uses custom batch size', () => {
|
|
169
|
+
const config = validatePhoenixConfig({
|
|
170
|
+
batchSize: 50,
|
|
171
|
+
});
|
|
172
|
+
assert.strictEqual(config.batchSize, 50);
|
|
173
|
+
});
|
|
174
|
+
it('throws when batch size too small', () => {
|
|
175
|
+
assert.throws(() => validatePhoenixConfig({ batchSize: 0 }), /batch size must be between/);
|
|
176
|
+
});
|
|
177
|
+
it('throws when batch size too large', () => {
|
|
178
|
+
assert.throws(() => validatePhoenixConfig({ batchSize: 1001 }), /batch size must be between/);
|
|
179
|
+
});
|
|
180
|
+
it('uses default timeout', () => {
|
|
181
|
+
const config = validatePhoenixConfig({});
|
|
182
|
+
assert.strictEqual(config.timeoutMs, 30000);
|
|
183
|
+
});
|
|
184
|
+
it('throws when timeout too small', () => {
|
|
185
|
+
assert.throws(() => validatePhoenixConfig({ timeoutMs: 500 }), /timeout must be between/);
|
|
186
|
+
});
|
|
187
|
+
it('throws when timeout too large', () => {
|
|
188
|
+
assert.throws(() => validatePhoenixConfig({ timeoutMs: 200000 }), /timeout must be between/);
|
|
189
|
+
});
|
|
190
|
+
it('uses legacyAuth default false', () => {
|
|
191
|
+
const config = validatePhoenixConfig({});
|
|
192
|
+
assert.strictEqual(config.legacyAuth, false);
|
|
193
|
+
});
|
|
194
|
+
it('accepts legacyAuth true', () => {
|
|
195
|
+
const config = validatePhoenixConfig({
|
|
196
|
+
legacyAuth: true,
|
|
197
|
+
});
|
|
198
|
+
assert.strictEqual(config.legacyAuth, true);
|
|
199
|
+
});
|
|
200
|
+
it('throws for invalid endpoint', () => {
|
|
201
|
+
assert.throws(() => validatePhoenixConfig({
|
|
202
|
+
endpoint: 'http://phoenix.internal:6006',
|
|
203
|
+
}), /endpoint not configured or invalid/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('createPhoenixAuthHeaders', () => {
|
|
207
|
+
it('returns empty object when no API key', () => {
|
|
208
|
+
const headers = createPhoenixAuthHeaders(undefined, false);
|
|
209
|
+
assert.deepStrictEqual(headers, {});
|
|
210
|
+
});
|
|
211
|
+
it('returns Bearer header for modern auth', () => {
|
|
212
|
+
const headers = createPhoenixAuthHeaders('phx_test_key', false);
|
|
213
|
+
assert.deepStrictEqual(headers, {
|
|
214
|
+
'Authorization': 'Bearer phx_test_key',
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
it('returns api_key header for legacy auth', () => {
|
|
218
|
+
const headers = createPhoenixAuthHeaders('phx_test_key', true);
|
|
219
|
+
assert.deepStrictEqual(headers, {
|
|
220
|
+
'api_key': 'phx_test_key',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
it('handles API keys with special characters', () => {
|
|
224
|
+
const headers = createPhoenixAuthHeaders('phx_test-key_123', false);
|
|
225
|
+
assert.strictEqual(headers['Authorization'], 'Bearer phx_test-key_123');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe('evaluationsToPhoenixTraces', () => {
|
|
229
|
+
const baseConfig = {
|
|
230
|
+
endpoint: 'http://localhost:6006',
|
|
231
|
+
projectName: 'test-project',
|
|
232
|
+
batchSize: 100,
|
|
233
|
+
timeoutMs: 30000,
|
|
234
|
+
};
|
|
235
|
+
const baseEvaluation = {
|
|
236
|
+
timestamp: '2024-01-15T10:00:00Z',
|
|
237
|
+
evaluationName: 'relevance',
|
|
238
|
+
scoreValue: 0.85,
|
|
239
|
+
scoreLabel: 'good',
|
|
240
|
+
scoreUnit: 'ratio_0_1',
|
|
241
|
+
explanation: 'The response was relevant',
|
|
242
|
+
evaluator: 'gpt-4',
|
|
243
|
+
evaluatorType: 'llm',
|
|
244
|
+
responseId: 'resp-123',
|
|
245
|
+
traceId: 'abc123def456',
|
|
246
|
+
sessionId: 'session-1',
|
|
247
|
+
};
|
|
248
|
+
it('converts single evaluation to OTLP trace', () => {
|
|
249
|
+
const result = evaluationsToPhoenixTraces([baseEvaluation], baseConfig);
|
|
250
|
+
assert.strictEqual(result.resourceSpans.length, 1);
|
|
251
|
+
assert.strictEqual(result.resourceSpans[0].scopeSpans.length, 1);
|
|
252
|
+
assert.strictEqual(result.resourceSpans[0].scopeSpans[0].spans.length, 1);
|
|
253
|
+
const span = result.resourceSpans[0].scopeSpans[0].spans[0];
|
|
254
|
+
assert.strictEqual(span.traceId, 'abc123def456');
|
|
255
|
+
assert.strictEqual(span.name, 'evaluation_export');
|
|
256
|
+
assert.ok(span.events);
|
|
257
|
+
assert.strictEqual(span.events.length, 1);
|
|
258
|
+
const event = span.events[0];
|
|
259
|
+
assert.strictEqual(event.name, 'gen_ai.evaluation.result');
|
|
260
|
+
});
|
|
261
|
+
it('includes GenAI evaluation attributes', () => {
|
|
262
|
+
const result = evaluationsToPhoenixTraces([baseEvaluation], baseConfig);
|
|
263
|
+
const event = result.resourceSpans[0].scopeSpans[0].spans[0].events?.[0];
|
|
264
|
+
const attrs = Object.fromEntries(event.attributes.map(a => [a.key, a.value.stringValue ?? a.value.doubleValue]));
|
|
265
|
+
assert.strictEqual(attrs['gen_ai.evaluation.name'], 'relevance');
|
|
266
|
+
assert.strictEqual(attrs['gen_ai.evaluation.score.value'], 0.85);
|
|
267
|
+
assert.strictEqual(attrs['gen_ai.evaluation.score.label'], 'good');
|
|
268
|
+
assert.strictEqual(attrs['gen_ai.evaluation.explanation'], 'The response was relevant');
|
|
269
|
+
assert.strictEqual(attrs['gen_ai.evaluation.evaluator'], 'gpt-4');
|
|
270
|
+
assert.strictEqual(attrs['gen_ai.evaluation.evaluator_type'], 'llm');
|
|
271
|
+
});
|
|
272
|
+
it('includes session.id attribute for session tracking', () => {
|
|
273
|
+
const result = evaluationsToPhoenixTraces([baseEvaluation], baseConfig);
|
|
274
|
+
const spanAttrs = result.resourceSpans[0].scopeSpans[0].spans[0].attributes;
|
|
275
|
+
const sessionAttr = spanAttrs?.find(a => a.key === 'session.id');
|
|
276
|
+
assert.ok(sessionAttr);
|
|
277
|
+
assert.strictEqual(sessionAttr.value.stringValue, 'session-1');
|
|
278
|
+
});
|
|
279
|
+
it('includes phoenix.project.name in resource attributes', () => {
|
|
280
|
+
const result = evaluationsToPhoenixTraces([baseEvaluation], baseConfig);
|
|
281
|
+
const resourceAttrs = result.resourceSpans[0].resource.attributes;
|
|
282
|
+
const projectAttr = resourceAttrs.find(a => a.key === 'phoenix.project.name');
|
|
283
|
+
assert.ok(projectAttr);
|
|
284
|
+
assert.strictEqual(projectAttr.value.stringValue, 'test-project');
|
|
285
|
+
});
|
|
286
|
+
it('includes export.type attribute', () => {
|
|
287
|
+
const result = evaluationsToPhoenixTraces([baseEvaluation], baseConfig);
|
|
288
|
+
const spanAttrs = result.resourceSpans[0].scopeSpans[0].spans[0].attributes;
|
|
289
|
+
const exportTypeAttr = spanAttrs?.find(a => a.key === 'export.type');
|
|
290
|
+
assert.ok(exportTypeAttr);
|
|
291
|
+
assert.strictEqual(exportTypeAttr.value.stringValue, 'phoenix');
|
|
292
|
+
});
|
|
293
|
+
it('groups multiple evaluations by traceId', () => {
|
|
294
|
+
const evaluations = [
|
|
295
|
+
{ ...baseEvaluation, traceId: 'trace-1' },
|
|
296
|
+
{ ...baseEvaluation, traceId: 'trace-1', evaluationName: 'quality' },
|
|
297
|
+
{ ...baseEvaluation, traceId: 'trace-2' },
|
|
298
|
+
];
|
|
299
|
+
const result = evaluationsToPhoenixTraces(evaluations, baseConfig);
|
|
300
|
+
const spans = result.resourceSpans[0].scopeSpans[0].spans;
|
|
301
|
+
assert.strictEqual(spans.length, 2);
|
|
302
|
+
const trace1Span = spans.find(s => s.traceId === 'trace-1');
|
|
303
|
+
assert.ok(trace1Span);
|
|
304
|
+
assert.strictEqual(trace1Span.events?.length, 2);
|
|
305
|
+
const trace2Span = spans.find(s => s.traceId === 'trace-2');
|
|
306
|
+
assert.ok(trace2Span);
|
|
307
|
+
assert.strictEqual(trace2Span.events?.length, 1);
|
|
308
|
+
});
|
|
309
|
+
it('generates traceId when not provided', () => {
|
|
310
|
+
const evaluation = { ...baseEvaluation };
|
|
311
|
+
delete evaluation.traceId;
|
|
312
|
+
const result = evaluationsToPhoenixTraces([evaluation], baseConfig);
|
|
313
|
+
const span = result.resourceSpans[0].scopeSpans[0].spans[0];
|
|
314
|
+
assert.ok(span.traceId);
|
|
315
|
+
assert.strictEqual(span.traceId.length, 32);
|
|
316
|
+
});
|
|
317
|
+
it('handles evaluations with missing optional fields', () => {
|
|
318
|
+
const minimalEvaluation = {
|
|
319
|
+
timestamp: '2024-01-15T10:00:00Z',
|
|
320
|
+
evaluationName: 'test',
|
|
321
|
+
};
|
|
322
|
+
const result = evaluationsToPhoenixTraces([minimalEvaluation], baseConfig);
|
|
323
|
+
const event = result.resourceSpans[0].scopeSpans[0].spans[0].events?.[0];
|
|
324
|
+
assert.ok(event);
|
|
325
|
+
assert.strictEqual(event.name, 'gen_ai.evaluation.result');
|
|
326
|
+
const evalNameAttr = event.attributes?.find(a => a.key === 'gen_ai.evaluation.name');
|
|
327
|
+
assert.ok(evalNameAttr);
|
|
328
|
+
assert.strictEqual(evalNameAttr.value.stringValue, 'test');
|
|
329
|
+
});
|
|
330
|
+
it('handles empty evaluations array', () => {
|
|
331
|
+
const result = evaluationsToPhoenixTraces([], baseConfig);
|
|
332
|
+
assert.strictEqual(result.resourceSpans[0].scopeSpans[0].spans.length, 0);
|
|
333
|
+
});
|
|
334
|
+
describe('timestamp handling', () => {
|
|
335
|
+
it('accepts valid timestamps within bounds', () => {
|
|
336
|
+
const evaluation = {
|
|
337
|
+
timestamp: '2024-01-15T10:00:00Z',
|
|
338
|
+
evaluationName: 'test',
|
|
339
|
+
};
|
|
340
|
+
const result = evaluationsToPhoenixTraces([evaluation], baseConfig);
|
|
341
|
+
const span = result.resourceSpans[0].scopeSpans[0].spans[0];
|
|
342
|
+
const startNano = BigInt(span.startTimeUnixNano);
|
|
343
|
+
const expectedMs = new Date('2024-01-15T10:00:00Z').getTime();
|
|
344
|
+
const expectedNs = BigInt(expectedMs) * BigInt(1_000_000);
|
|
345
|
+
assert.strictEqual(startNano, expectedNs);
|
|
346
|
+
});
|
|
347
|
+
it('uses fallback for invalid date strings', () => {
|
|
348
|
+
const invalidEval = {
|
|
349
|
+
timestamp: 'not-a-valid-date',
|
|
350
|
+
evaluationName: 'test-invalid',
|
|
351
|
+
};
|
|
352
|
+
const result = evaluationsToPhoenixTraces([invalidEval], baseConfig);
|
|
353
|
+
const span = result.resourceSpans[0].scopeSpans[0].spans[0];
|
|
354
|
+
const event = span.events?.[0];
|
|
355
|
+
assert.ok(span.startTimeUnixNano);
|
|
356
|
+
assert.ok(event?.timeUnixNano);
|
|
357
|
+
});
|
|
358
|
+
it('uses fallback for extreme timestamps', () => {
|
|
359
|
+
const extremeEval = {
|
|
360
|
+
timestamp: '9999-12-31T23:59:59Z',
|
|
361
|
+
evaluationName: 'test-extreme',
|
|
362
|
+
};
|
|
363
|
+
const result = evaluationsToPhoenixTraces([extremeEval], baseConfig);
|
|
364
|
+
const span = result.resourceSpans[0].scopeSpans[0].spans[0];
|
|
365
|
+
const startNano = BigInt(span.startTimeUnixNano);
|
|
366
|
+
const year9999Ms = new Date('9999-12-31T23:59:59Z').getTime();
|
|
367
|
+
const year9999Ns = BigInt(year9999Ms) * BigInt(1_000_000);
|
|
368
|
+
assert.ok(startNano < year9999Ns, 'Should use fallback time');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
describe('exportToPhoenix', () => {
|
|
373
|
+
const mockConfig = {
|
|
374
|
+
endpoint: 'http://localhost:6006',
|
|
375
|
+
projectName: 'test-project',
|
|
376
|
+
batchSize: 2,
|
|
377
|
+
timeoutMs: 5000,
|
|
378
|
+
};
|
|
379
|
+
const testEvaluations = [
|
|
380
|
+
{ timestamp: '2024-01-15T10:00:00Z', evaluationName: 'eval1', scoreValue: 0.8 },
|
|
381
|
+
{ timestamp: '2024-01-15T10:00:01Z', evaluationName: 'eval2', scoreValue: 0.9 },
|
|
382
|
+
{ timestamp: '2024-01-15T10:00:02Z', evaluationName: 'eval3', scoreValue: 0.7 },
|
|
383
|
+
];
|
|
384
|
+
it('batches evaluations correctly', async () => {
|
|
385
|
+
const fetchCalls = [];
|
|
386
|
+
const originalFetch = globalThis.fetch;
|
|
387
|
+
globalThis.fetch = mock.fn(async (url, opts) => {
|
|
388
|
+
fetchCalls.push({ url: url.toString(), body: opts?.body });
|
|
389
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
const result = await exportToPhoenix(testEvaluations, mockConfig);
|
|
393
|
+
assert.strictEqual(result.success, true);
|
|
394
|
+
assert.strictEqual(result.evaluationsExported, 3);
|
|
395
|
+
assert.strictEqual(result.batches, 2);
|
|
396
|
+
assert.strictEqual(result.failed, 0);
|
|
397
|
+
assert.ok(result.durationMs >= 0);
|
|
398
|
+
assert.strictEqual(fetchCalls.length, 2);
|
|
399
|
+
assert.ok(fetchCalls[0].url.includes('/v1/traces'));
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
globalThis.fetch = originalFetch;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
it('handles HTTP errors', async () => {
|
|
406
|
+
const originalFetch = globalThis.fetch;
|
|
407
|
+
globalThis.fetch = mock.fn(async () => {
|
|
408
|
+
return new Response('Unauthorized', { status: HttpStatus.UNAUTHORIZED });
|
|
409
|
+
});
|
|
410
|
+
try {
|
|
411
|
+
const result = await exportToPhoenix([testEvaluations[0]], mockConfig);
|
|
412
|
+
assert.strictEqual(result.success, false);
|
|
413
|
+
assert.strictEqual(result.evaluationsExported, 0);
|
|
414
|
+
assert.strictEqual(result.failed, 1);
|
|
415
|
+
assert.ok(result.errors);
|
|
416
|
+
assert.ok(result.errors[0].includes('HTTP 401'));
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
globalThis.fetch = originalFetch;
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
it('handles network errors', async () => {
|
|
423
|
+
const originalFetch = globalThis.fetch;
|
|
424
|
+
globalThis.fetch = mock.fn(async () => {
|
|
425
|
+
throw new Error('Network error');
|
|
426
|
+
});
|
|
427
|
+
try {
|
|
428
|
+
const result = await exportToPhoenix([testEvaluations[0]], mockConfig);
|
|
429
|
+
assert.strictEqual(result.success, false);
|
|
430
|
+
assert.strictEqual(result.failed, 1);
|
|
431
|
+
assert.ok(result.errors);
|
|
432
|
+
assert.ok(result.errors[0].includes('Network error'));
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
globalThis.fetch = originalFetch;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
it('handles empty evaluations array', async () => {
|
|
439
|
+
const result = await exportToPhoenix([], mockConfig);
|
|
440
|
+
assert.strictEqual(result.success, true);
|
|
441
|
+
assert.strictEqual(result.evaluationsExported, 0);
|
|
442
|
+
assert.strictEqual(result.batches, 0);
|
|
443
|
+
assert.strictEqual(result.failed, 0);
|
|
444
|
+
});
|
|
445
|
+
it('includes Bearer auth header when API key provided', async () => {
|
|
446
|
+
let capturedHeaders = {};
|
|
447
|
+
const originalFetch = globalThis.fetch;
|
|
448
|
+
globalThis.fetch = mock.fn(async (_url, opts) => {
|
|
449
|
+
capturedHeaders = Object.fromEntries(Object.entries(opts?.headers || {}));
|
|
450
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
451
|
+
});
|
|
452
|
+
try {
|
|
453
|
+
await exportToPhoenix([testEvaluations[0]], {
|
|
454
|
+
...mockConfig,
|
|
455
|
+
apiKey: 'phx_test_key',
|
|
456
|
+
});
|
|
457
|
+
assert.ok(capturedHeaders['Authorization']);
|
|
458
|
+
assert.strictEqual(capturedHeaders['Authorization'], 'Bearer phx_test_key');
|
|
459
|
+
assert.strictEqual(capturedHeaders['Content-Type'], 'application/json');
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
globalThis.fetch = originalFetch;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
it('includes api_key header for legacy auth', async () => {
|
|
466
|
+
let capturedHeaders = {};
|
|
467
|
+
const originalFetch = globalThis.fetch;
|
|
468
|
+
globalThis.fetch = mock.fn(async (_url, opts) => {
|
|
469
|
+
capturedHeaders = Object.fromEntries(Object.entries(opts?.headers || {}));
|
|
470
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
471
|
+
});
|
|
472
|
+
try {
|
|
473
|
+
await exportToPhoenix([testEvaluations[0]], {
|
|
474
|
+
...mockConfig,
|
|
475
|
+
apiKey: 'phx_test_key',
|
|
476
|
+
legacyAuth: true,
|
|
477
|
+
});
|
|
478
|
+
assert.ok(capturedHeaders['api_key']);
|
|
479
|
+
assert.strictEqual(capturedHeaders['api_key'], 'phx_test_key');
|
|
480
|
+
assert.ok(!capturedHeaders['Authorization']);
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
globalThis.fetch = originalFetch;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
it('does not include auth headers when no API key', async () => {
|
|
487
|
+
let capturedHeaders = {};
|
|
488
|
+
const originalFetch = globalThis.fetch;
|
|
489
|
+
globalThis.fetch = mock.fn(async (_url, opts) => {
|
|
490
|
+
capturedHeaders = Object.fromEntries(Object.entries(opts?.headers || {}));
|
|
491
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
await exportToPhoenix([testEvaluations[0]], mockConfig);
|
|
495
|
+
assert.ok(!capturedHeaders['Authorization']);
|
|
496
|
+
assert.ok(!capturedHeaders['api_key']);
|
|
497
|
+
assert.strictEqual(capturedHeaders['Content-Type'], 'application/json');
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
globalThis.fetch = originalFetch;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
it('handles partial batch failures', async () => {
|
|
504
|
+
let callCount = 0;
|
|
505
|
+
const originalFetch = globalThis.fetch;
|
|
506
|
+
globalThis.fetch = mock.fn(async () => {
|
|
507
|
+
callCount++;
|
|
508
|
+
if (callCount === 1) {
|
|
509
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
510
|
+
}
|
|
511
|
+
return new Response('Server Error', { status: HttpStatus.INTERNAL_SERVER_ERROR });
|
|
512
|
+
});
|
|
513
|
+
try {
|
|
514
|
+
const result = await exportToPhoenix(testEvaluations, {
|
|
515
|
+
...mockConfig,
|
|
516
|
+
batchSize: 2,
|
|
517
|
+
});
|
|
518
|
+
assert.strictEqual(result.success, false);
|
|
519
|
+
assert.strictEqual(result.evaluationsExported, 2);
|
|
520
|
+
assert.strictEqual(result.failed, 1);
|
|
521
|
+
assert.strictEqual(result.batches, 2);
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
globalThis.fetch = originalFetch;
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
it('generates projectUrl for Phoenix Cloud', async () => {
|
|
528
|
+
const originalFetch = globalThis.fetch;
|
|
529
|
+
globalThis.fetch = mock.fn(async () => {
|
|
530
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
531
|
+
});
|
|
532
|
+
try {
|
|
533
|
+
const result = await exportToPhoenix([testEvaluations[0]], {
|
|
534
|
+
...mockConfig,
|
|
535
|
+
endpoint: 'https://app.phoenix.arize.com/s/my-space',
|
|
536
|
+
apiKey: 'phx_test_key',
|
|
537
|
+
projectName: 'my-project',
|
|
538
|
+
});
|
|
539
|
+
assert.strictEqual(result.success, true);
|
|
540
|
+
assert.ok(result.projectUrl);
|
|
541
|
+
assert.ok(result.projectUrl.includes('my-space'));
|
|
542
|
+
assert.ok(result.projectUrl.includes('my-project'));
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
globalThis.fetch = originalFetch;
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
describe('credential sanitization in error responses', () => {
|
|
549
|
+
it('sanitizes Bearer tokens in error text', async () => {
|
|
550
|
+
const originalFetch = globalThis.fetch;
|
|
551
|
+
const originalConsoleError = console.error;
|
|
552
|
+
let loggedMessage = '';
|
|
553
|
+
console.error = (msg) => { loggedMessage = msg; };
|
|
554
|
+
globalThis.fetch = mock.fn(async () => {
|
|
555
|
+
return new Response('Invalid Bearer phx_secret_key_12345', { status: HttpStatus.UNAUTHORIZED });
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
await exportToPhoenix([testEvaluations[0]], {
|
|
559
|
+
...mockConfig,
|
|
560
|
+
apiKey: 'phx_secret_key_12345',
|
|
561
|
+
});
|
|
562
|
+
assert.ok(!loggedMessage.includes('phx_secret_key_12345'), 'API key should be redacted');
|
|
563
|
+
assert.ok(loggedMessage.includes('[REDACTED]'), 'Should contain [REDACTED]');
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
globalThis.fetch = originalFetch;
|
|
567
|
+
console.error = originalConsoleError;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
describe('memory protection', () => {
|
|
572
|
+
it('aborts export when memory exceeds threshold', async () => {
|
|
573
|
+
const originalMemoryUsage = process.memoryUsage;
|
|
574
|
+
let memoryCallCount = 0;
|
|
575
|
+
process.memoryUsage = (() => {
|
|
576
|
+
memoryCallCount++;
|
|
577
|
+
const heapUsed = memoryCallCount > 1 ? 700 * 1024 * 1024 : 100 * 1024 * 1024;
|
|
578
|
+
return {
|
|
579
|
+
heapUsed,
|
|
580
|
+
heapTotal: 1024 * 1024 * 1024,
|
|
581
|
+
external: 0,
|
|
582
|
+
arrayBuffers: 0,
|
|
583
|
+
rss: 0,
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
const originalFetch = globalThis.fetch;
|
|
587
|
+
globalThis.fetch = mock.fn(async () => {
|
|
588
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
589
|
+
});
|
|
590
|
+
try {
|
|
591
|
+
const manyEvaluations = Array.from({ length: 100 }, (_, i) => ({
|
|
592
|
+
timestamp: '2024-01-15T10:00:00Z',
|
|
593
|
+
evaluationName: `eval-${i}`,
|
|
594
|
+
scoreValue: 0.5,
|
|
595
|
+
}));
|
|
596
|
+
const result = await exportToPhoenix(manyEvaluations, {
|
|
597
|
+
...mockConfig,
|
|
598
|
+
batchSize: 10,
|
|
599
|
+
});
|
|
600
|
+
assert.strictEqual(result.success, false);
|
|
601
|
+
assert.ok(result.failed > 0);
|
|
602
|
+
assert.ok(result.errors?.some(e => e.includes('Memory limit exceeded')));
|
|
603
|
+
assert.ok(result.evaluationsExported < 100);
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
process.memoryUsage = originalMemoryUsage;
|
|
607
|
+
globalThis.fetch = originalFetch;
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
describe('DNS rebinding protection', () => {
|
|
612
|
+
it('re-validates endpoint before each batch', async () => {
|
|
613
|
+
let fetchCallCount = 0;
|
|
614
|
+
const originalFetch = globalThis.fetch;
|
|
615
|
+
globalThis.fetch = mock.fn(async () => {
|
|
616
|
+
fetchCallCount++;
|
|
617
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
618
|
+
});
|
|
619
|
+
try {
|
|
620
|
+
const result = await exportToPhoenix(testEvaluations, {
|
|
621
|
+
...mockConfig,
|
|
622
|
+
batchSize: 1,
|
|
623
|
+
});
|
|
624
|
+
assert.strictEqual(result.success, true);
|
|
625
|
+
assert.strictEqual(result.batches, 3);
|
|
626
|
+
assert.strictEqual(fetchCallCount, 3);
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
globalThis.fetch = originalFetch;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
it('fails batch if endpoint becomes invalid mid-export', async () => {
|
|
633
|
+
let fetchCallCount = 0;
|
|
634
|
+
const originalFetch = globalThis.fetch;
|
|
635
|
+
globalThis.fetch = mock.fn(async () => {
|
|
636
|
+
fetchCallCount++;
|
|
637
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
638
|
+
});
|
|
639
|
+
try {
|
|
640
|
+
const result = await exportToPhoenix(testEvaluations, {
|
|
641
|
+
...mockConfig,
|
|
642
|
+
endpoint: 'http://phoenix.internal:6006',
|
|
643
|
+
batchSize: 1,
|
|
644
|
+
});
|
|
645
|
+
assert.strictEqual(result.success, false);
|
|
646
|
+
assert.strictEqual(result.failed, 3);
|
|
647
|
+
assert.strictEqual(fetchCallCount, 0);
|
|
648
|
+
assert.ok(result.errors?.some(e => e.includes('Endpoint validation failed')));
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
globalThis.fetch = originalFetch;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
describe('retry logic', () => {
|
|
656
|
+
it('retries on 429 rate limit response', async () => {
|
|
657
|
+
let fetchCallCount = 0;
|
|
658
|
+
const originalFetch = globalThis.fetch;
|
|
659
|
+
globalThis.fetch = mock.fn(async () => {
|
|
660
|
+
fetchCallCount++;
|
|
661
|
+
if (fetchCallCount <= 2) {
|
|
662
|
+
return new Response('Rate limited', { status: HttpStatus.TOO_MANY_REQUESTS });
|
|
663
|
+
}
|
|
664
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
665
|
+
});
|
|
666
|
+
try {
|
|
667
|
+
const result = await exportToPhoenix([testEvaluations[0]], {
|
|
668
|
+
...mockConfig,
|
|
669
|
+
timeoutMs: 100,
|
|
670
|
+
});
|
|
671
|
+
assert.strictEqual(result.success, true);
|
|
672
|
+
assert.strictEqual(result.evaluationsExported, 1);
|
|
673
|
+
assert.strictEqual(fetchCallCount, 3);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
globalThis.fetch = originalFetch;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
it('retries on 500 server error', async () => {
|
|
680
|
+
let fetchCallCount = 0;
|
|
681
|
+
const originalFetch = globalThis.fetch;
|
|
682
|
+
globalThis.fetch = mock.fn(async () => {
|
|
683
|
+
fetchCallCount++;
|
|
684
|
+
if (fetchCallCount === 1) {
|
|
685
|
+
return new Response('Internal Server Error', { status: HttpStatus.INTERNAL_SERVER_ERROR });
|
|
686
|
+
}
|
|
687
|
+
return new Response('{}', { status: HttpStatus.OK });
|
|
688
|
+
});
|
|
689
|
+
try {
|
|
690
|
+
const result = await exportToPhoenix([testEvaluations[0]], {
|
|
691
|
+
...mockConfig,
|
|
692
|
+
timeoutMs: 100,
|
|
693
|
+
});
|
|
694
|
+
assert.strictEqual(result.success, true);
|
|
695
|
+
assert.strictEqual(result.evaluationsExported, 1);
|
|
696
|
+
assert.strictEqual(fetchCallCount, 2);
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
globalThis.fetch = originalFetch;
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
it('does not retry on 400 bad request', async () => {
|
|
703
|
+
let fetchCallCount = 0;
|
|
704
|
+
const originalFetch = globalThis.fetch;
|
|
705
|
+
globalThis.fetch = mock.fn(async () => {
|
|
706
|
+
fetchCallCount++;
|
|
707
|
+
return new Response('Bad Request', { status: HttpStatus.BAD_REQUEST });
|
|
708
|
+
});
|
|
709
|
+
try {
|
|
710
|
+
const result = await exportToPhoenix([testEvaluations[0]], {
|
|
711
|
+
...mockConfig,
|
|
712
|
+
timeoutMs: 100,
|
|
713
|
+
});
|
|
714
|
+
assert.strictEqual(result.success, false);
|
|
715
|
+
assert.strictEqual(result.failed, 1);
|
|
716
|
+
assert.strictEqual(fetchCallCount, 1);
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
globalThis.fetch = originalFetch;
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
//# sourceMappingURL=phoenix-export.test.js.map
|