@arizeai/phoenix-client 6.10.1 → 6.11.1

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.
Files changed (214) hide show
  1. package/README.md +62 -0
  2. package/dist/esm/__generated__/api/v1.d.ts +254 -2
  3. package/dist/esm/__generated__/api/v1.d.ts.map +1 -1
  4. package/dist/esm/jest/index.d.ts +5 -0
  5. package/dist/esm/jest/index.d.ts.map +1 -0
  6. package/dist/esm/jest/index.js +49 -0
  7. package/dist/esm/jest/index.js.map +1 -0
  8. package/dist/esm/jest/reporter.d.ts +13 -0
  9. package/dist/esm/jest/reporter.d.ts.map +1 -0
  10. package/dist/esm/jest/reporter.js +19 -0
  11. package/dist/esm/jest/reporter.js.map +1 -0
  12. package/dist/esm/prompts/sdks/toAI.d.ts +2 -2
  13. package/dist/esm/prompts/sdks/toAI.d.ts.map +1 -1
  14. package/dist/esm/prompts/sdks/toAI.js.map +1 -1
  15. package/dist/esm/prompts/sdks/toAnthropic.d.ts +2 -2
  16. package/dist/esm/prompts/sdks/toAnthropic.d.ts.map +1 -1
  17. package/dist/esm/prompts/sdks/toAnthropic.js.map +1 -1
  18. package/dist/esm/prompts/sdks/toOpenAI.d.ts +2 -2
  19. package/dist/esm/prompts/sdks/toOpenAI.d.ts.map +1 -1
  20. package/dist/esm/prompts/sdks/toOpenAI.js.map +1 -1
  21. package/dist/esm/prompts/sdks/toSDK.d.ts +8 -8
  22. package/dist/esm/prompts/sdks/toSDK.d.ts.map +1 -1
  23. package/dist/esm/prompts/sdks/toSDK.js.map +1 -1
  24. package/dist/esm/prompts/sdks/types.d.ts +2 -2
  25. package/dist/esm/prompts/sdks/types.d.ts.map +1 -1
  26. package/dist/esm/schemas/llm/anthropic/converters.d.ts +8 -8
  27. package/dist/esm/schemas/llm/anthropic/messagePartSchemas.d.ts +4 -4
  28. package/dist/esm/schemas/llm/anthropic/messageSchemas.d.ts +6 -6
  29. package/dist/esm/schemas/llm/constants.d.ts +3 -3
  30. package/dist/esm/schemas/llm/converters.d.ts +12 -12
  31. package/dist/esm/schemas/llm/openai/converters.d.ts +3 -3
  32. package/dist/esm/schemas/llm/schemas.d.ts +2 -2
  33. package/dist/esm/testing/acceptance.d.ts +20 -0
  34. package/dist/esm/testing/acceptance.d.ts.map +1 -0
  35. package/dist/esm/testing/acceptance.js +129 -0
  36. package/dist/esm/testing/acceptance.js.map +1 -0
  37. package/dist/esm/testing/define-api.d.ts +157 -0
  38. package/dist/esm/testing/define-api.d.ts.map +1 -0
  39. package/dist/esm/testing/define-api.js +78 -0
  40. package/dist/esm/testing/define-api.js.map +1 -0
  41. package/dist/esm/testing/helpers.d.ts +55 -0
  42. package/dist/esm/testing/helpers.d.ts.map +1 -0
  43. package/dist/esm/testing/helpers.js +179 -0
  44. package/dist/esm/testing/helpers.js.map +1 -0
  45. package/dist/esm/testing/phoenix-test-tracking.d.ts +68 -0
  46. package/dist/esm/testing/phoenix-test-tracking.d.ts.map +1 -0
  47. package/dist/esm/testing/phoenix-test-tracking.js +521 -0
  48. package/dist/esm/testing/phoenix-test-tracking.js.map +1 -0
  49. package/dist/esm/testing/report-artifacts.d.ts +45 -0
  50. package/dist/esm/testing/report-artifacts.d.ts.map +1 -0
  51. package/dist/esm/testing/report-artifacts.js +218 -0
  52. package/dist/esm/testing/report-artifacts.js.map +1 -0
  53. package/dist/esm/testing/report-run.d.ts +22 -0
  54. package/dist/esm/testing/report-run.d.ts.map +1 -0
  55. package/dist/esm/testing/report-run.js +41 -0
  56. package/dist/esm/testing/report-run.js.map +1 -0
  57. package/dist/esm/testing/reporter-format.d.ts +83 -0
  58. package/dist/esm/testing/reporter-format.d.ts.map +1 -0
  59. package/dist/esm/testing/reporter-format.js +852 -0
  60. package/dist/esm/testing/reporter-format.js.map +1 -0
  61. package/dist/esm/testing/runner.d.ts +31 -0
  62. package/dist/esm/testing/runner.d.ts.map +1 -0
  63. package/dist/esm/testing/runner.js +238 -0
  64. package/dist/esm/testing/runner.js.map +1 -0
  65. package/dist/esm/testing/state.d.ts +138 -0
  66. package/dist/esm/testing/state.d.ts.map +1 -0
  67. package/dist/esm/testing/state.js +31 -0
  68. package/dist/esm/testing/state.js.map +1 -0
  69. package/dist/esm/testing/types.d.ts +319 -0
  70. package/dist/esm/testing/types.d.ts.map +1 -0
  71. package/dist/esm/testing/types.js +9 -0
  72. package/dist/esm/testing/types.js.map +1 -0
  73. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  74. package/dist/esm/utils/channel.d.ts +7 -7
  75. package/dist/esm/utils/channel.d.ts.map +1 -1
  76. package/dist/esm/utils/channel.js +1 -1
  77. package/dist/esm/utils/channel.js.map +1 -1
  78. package/dist/esm/utils/formatPromptMessages.d.ts.map +1 -1
  79. package/dist/esm/utils/getPromptBySelector.d.ts.map +1 -1
  80. package/dist/esm/utils/promisifyResult.d.ts +1 -1
  81. package/dist/esm/utils/promisifyResult.d.ts.map +1 -1
  82. package/dist/esm/utils/promisifyResult.js.map +1 -1
  83. package/dist/esm/utils/schemaMatches.d.ts +5 -5
  84. package/dist/esm/utils/schemaMatches.d.ts.map +1 -1
  85. package/dist/esm/utils/schemaMatches.js.map +1 -1
  86. package/dist/esm/vitest/index.d.ts +5 -0
  87. package/dist/esm/vitest/index.d.ts.map +1 -0
  88. package/dist/esm/vitest/index.js +15 -0
  89. package/dist/esm/vitest/index.js.map +1 -0
  90. package/dist/esm/vitest/reporter.d.ts +19 -0
  91. package/dist/esm/vitest/reporter.d.ts.map +1 -0
  92. package/dist/esm/vitest/reporter.js +27 -0
  93. package/dist/esm/vitest/reporter.js.map +1 -0
  94. package/dist/src/__generated__/api/v1.d.ts +254 -2
  95. package/dist/src/__generated__/api/v1.d.ts.map +1 -1
  96. package/dist/src/jest/index.d.ts +5 -0
  97. package/dist/src/jest/index.d.ts.map +1 -0
  98. package/dist/src/jest/index.js +58 -0
  99. package/dist/src/jest/index.js.map +1 -0
  100. package/dist/src/jest/reporter.d.ts +13 -0
  101. package/dist/src/jest/reporter.d.ts.map +1 -0
  102. package/dist/src/jest/reporter.js +23 -0
  103. package/dist/src/jest/reporter.js.map +1 -0
  104. package/dist/src/prompts/sdks/toAI.d.ts +2 -2
  105. package/dist/src/prompts/sdks/toAI.d.ts.map +1 -1
  106. package/dist/src/prompts/sdks/toAI.js.map +1 -1
  107. package/dist/src/prompts/sdks/toAnthropic.d.ts +2 -2
  108. package/dist/src/prompts/sdks/toAnthropic.d.ts.map +1 -1
  109. package/dist/src/prompts/sdks/toAnthropic.js.map +1 -1
  110. package/dist/src/prompts/sdks/toOpenAI.d.ts +2 -2
  111. package/dist/src/prompts/sdks/toOpenAI.d.ts.map +1 -1
  112. package/dist/src/prompts/sdks/toOpenAI.js.map +1 -1
  113. package/dist/src/prompts/sdks/toSDK.d.ts +8 -8
  114. package/dist/src/prompts/sdks/toSDK.d.ts.map +1 -1
  115. package/dist/src/prompts/sdks/toSDK.js.map +1 -1
  116. package/dist/src/prompts/sdks/types.d.ts +2 -2
  117. package/dist/src/prompts/sdks/types.d.ts.map +1 -1
  118. package/dist/src/schemas/llm/anthropic/converters.d.ts +8 -8
  119. package/dist/src/schemas/llm/anthropic/messagePartSchemas.d.ts +4 -4
  120. package/dist/src/schemas/llm/anthropic/messageSchemas.d.ts +6 -6
  121. package/dist/src/schemas/llm/constants.d.ts +3 -3
  122. package/dist/src/schemas/llm/converters.d.ts +12 -12
  123. package/dist/src/schemas/llm/openai/converters.d.ts +3 -3
  124. package/dist/src/schemas/llm/schemas.d.ts +2 -2
  125. package/dist/src/testing/acceptance.d.ts +20 -0
  126. package/dist/src/testing/acceptance.d.ts.map +1 -0
  127. package/dist/src/testing/acceptance.js +114 -0
  128. package/dist/src/testing/acceptance.js.map +1 -0
  129. package/dist/src/testing/define-api.d.ts +157 -0
  130. package/dist/src/testing/define-api.d.ts.map +1 -0
  131. package/dist/src/testing/define-api.js +81 -0
  132. package/dist/src/testing/define-api.js.map +1 -0
  133. package/dist/src/testing/helpers.d.ts +55 -0
  134. package/dist/src/testing/helpers.d.ts.map +1 -0
  135. package/dist/src/testing/helpers.js +182 -0
  136. package/dist/src/testing/helpers.js.map +1 -0
  137. package/dist/src/testing/phoenix-test-tracking.d.ts +68 -0
  138. package/dist/src/testing/phoenix-test-tracking.d.ts.map +1 -0
  139. package/dist/src/testing/phoenix-test-tracking.js +530 -0
  140. package/dist/src/testing/phoenix-test-tracking.js.map +1 -0
  141. package/dist/src/testing/report-artifacts.d.ts +45 -0
  142. package/dist/src/testing/report-artifacts.d.ts.map +1 -0
  143. package/dist/src/testing/report-artifacts.js +225 -0
  144. package/dist/src/testing/report-artifacts.js.map +1 -0
  145. package/dist/src/testing/report-run.d.ts +22 -0
  146. package/dist/src/testing/report-run.d.ts.map +1 -0
  147. package/dist/src/testing/report-run.js +47 -0
  148. package/dist/src/testing/report-run.js.map +1 -0
  149. package/dist/src/testing/reporter-format.d.ts +83 -0
  150. package/dist/src/testing/reporter-format.d.ts.map +1 -0
  151. package/dist/src/testing/reporter-format.js +870 -0
  152. package/dist/src/testing/reporter-format.js.map +1 -0
  153. package/dist/src/testing/runner.d.ts +31 -0
  154. package/dist/src/testing/runner.d.ts.map +1 -0
  155. package/dist/src/testing/runner.js +258 -0
  156. package/dist/src/testing/runner.js.map +1 -0
  157. package/dist/src/testing/state.d.ts +138 -0
  158. package/dist/src/testing/state.d.ts.map +1 -0
  159. package/dist/src/testing/state.js +38 -0
  160. package/dist/src/testing/state.js.map +1 -0
  161. package/dist/src/testing/types.d.ts +319 -0
  162. package/dist/src/testing/types.d.ts.map +1 -0
  163. package/dist/src/testing/types.js +13 -0
  164. package/dist/src/testing/types.js.map +1 -0
  165. package/dist/src/utils/channel.d.ts +7 -7
  166. package/dist/src/utils/channel.d.ts.map +1 -1
  167. package/dist/src/utils/channel.js +1 -1
  168. package/dist/src/utils/channel.js.map +1 -1
  169. package/dist/src/utils/formatPromptMessages.d.ts.map +1 -1
  170. package/dist/src/utils/getPromptBySelector.d.ts.map +1 -1
  171. package/dist/src/utils/promisifyResult.d.ts +1 -1
  172. package/dist/src/utils/promisifyResult.d.ts.map +1 -1
  173. package/dist/src/utils/promisifyResult.js.map +1 -1
  174. package/dist/src/utils/schemaMatches.d.ts +5 -5
  175. package/dist/src/utils/schemaMatches.d.ts.map +1 -1
  176. package/dist/src/utils/schemaMatches.js.map +1 -1
  177. package/dist/src/vitest/index.d.ts +5 -0
  178. package/dist/src/vitest/index.d.ts.map +1 -0
  179. package/dist/src/vitest/index.js +23 -0
  180. package/dist/src/vitest/index.js.map +1 -0
  181. package/dist/src/vitest/reporter.d.ts +19 -0
  182. package/dist/src/vitest/reporter.d.ts.map +1 -0
  183. package/dist/src/vitest/reporter.js +34 -0
  184. package/dist/src/vitest/reporter.js.map +1 -0
  185. package/dist/tsconfig.tsbuildinfo +1 -1
  186. package/docs/ci-evals-annotations.mdx +190 -0
  187. package/docs/ci-evals-jest.mdx +78 -0
  188. package/docs/ci-evals-vitest.mdx +240 -0
  189. package/docs/ci-evals.mdx +263 -0
  190. package/docs/overview.mdx +9 -1
  191. package/package.json +49 -17
  192. package/src/__generated__/api/v1.ts +254 -2
  193. package/src/jest/index.ts +124 -0
  194. package/src/jest/reporter.ts +22 -0
  195. package/src/prompts/sdks/toAI.ts +4 -3
  196. package/src/prompts/sdks/toAnthropic.ts +4 -3
  197. package/src/prompts/sdks/toOpenAI.ts +4 -3
  198. package/src/prompts/sdks/toSDK.ts +16 -11
  199. package/src/prompts/sdks/types.ts +2 -2
  200. package/src/testing/acceptance.ts +190 -0
  201. package/src/testing/define-api.ts +279 -0
  202. package/src/testing/helpers.ts +251 -0
  203. package/src/testing/phoenix-test-tracking.ts +637 -0
  204. package/src/testing/report-artifacts.ts +272 -0
  205. package/src/testing/report-run.ts +44 -0
  206. package/src/testing/reporter-format.ts +1072 -0
  207. package/src/testing/runner.ts +350 -0
  208. package/src/testing/state.ts +165 -0
  209. package/src/testing/types.ts +366 -0
  210. package/src/utils/channel.ts +17 -15
  211. package/src/utils/promisifyResult.ts +6 -4
  212. package/src/utils/schemaMatches.ts +12 -10
  213. package/src/vitest/index.ts +57 -0
  214. package/src/vitest/reporter.ts +32 -0
@@ -0,0 +1,637 @@
1
+ import {
2
+ attachGlobalTracerProvider,
3
+ context,
4
+ createNoOpProvider,
5
+ type GlobalTracerProviderRegistration,
6
+ MimeType,
7
+ type NodeTracerProvider,
8
+ OpenInferenceSpanKind,
9
+ register,
10
+ SemanticConventions,
11
+ SpanStatusCode,
12
+ suppressTracing,
13
+ trace,
14
+ type Tracer,
15
+ } from "@arizeai/phoenix-otel";
16
+ import type { Span } from "@opentelemetry/api";
17
+
18
+ import { createDataset } from "../datasets";
19
+ import { cleanupOwnedTracerProvider } from "../experiments/tracing";
20
+ import { createClient, type PhoenixClient } from "../index";
21
+ import { ensureString } from "../utils/ensureString";
22
+ import { toObjectHeaders } from "../utils/toObjectHeaders";
23
+ import {
24
+ getDatasetExperimentsUrl,
25
+ getDatasetUrl,
26
+ getExperimentUrl,
27
+ } from "../utils/urlUtils";
28
+ import { currentRun, type RunState, type SuiteState } from "./state";
29
+ import type { Annotation, KVMap } from "./types";
30
+
31
+ function isFalsyFlag(value: string | undefined): boolean {
32
+ const v = (value ?? "").toLowerCase();
33
+ return v === "false" || v === "0" || v === "off" || v === "no";
34
+ }
35
+
36
+ /**
37
+ * Decide whether tests should sync to Phoenix.
38
+ *
39
+ * Tracing is enabled by default. It can be disabled globally by setting
40
+ * `PHOENIX_TEST_TRACKING=false`, or per suite via `SuiteConfig.dryRun`.
41
+ */
42
+ export function isTrackingEnabled(suite?: SuiteState): {
43
+ enabled: boolean;
44
+ reason?: string;
45
+ } {
46
+ if (isFalsyFlag(process.env.PHOENIX_TEST_TRACKING)) {
47
+ return { enabled: false, reason: "PHOENIX_TEST_TRACKING is disabled" };
48
+ }
49
+ if (suite?.config.dryRun) {
50
+ return { enabled: false, reason: "suite configured dryRun" };
51
+ }
52
+ return { enabled: true };
53
+ }
54
+
55
+ /**
56
+ * Resolve the repetition count for a test: per-test value, else suite-level,
57
+ * else the `PHOENIX_TEST_REPETITIONS` env var, else `1`. Non-positive or
58
+ * non-finite values fall back to `1`.
59
+ */
60
+ export function resolveRepetitions(
61
+ perTest: number | undefined,
62
+ suite: SuiteState
63
+ ): number {
64
+ const envValue = Number(process.env.PHOENIX_TEST_REPETITIONS);
65
+ const candidates = [
66
+ perTest,
67
+ suite.config.repetitions,
68
+ Number.isFinite(envValue) ? envValue : undefined,
69
+ ];
70
+ for (const c of candidates) {
71
+ if (typeof c === "number" && Number.isFinite(c) && c >= 1) {
72
+ return Math.floor(c);
73
+ }
74
+ }
75
+ return 1;
76
+ }
77
+
78
+ /**
79
+ * Deterministic key for matching dataset examples by content. Uses sorted
80
+ * keys so two inputs that differ only in property order hash the same.
81
+ */
82
+ function stableKey(value: unknown): string {
83
+ return stableStringify(value);
84
+ }
85
+
86
+ function stableStringify(value: unknown): string {
87
+ if (value === null || typeof value !== "object") {
88
+ return JSON.stringify(value);
89
+ }
90
+ if (Array.isArray(value)) {
91
+ return "[" + value.map(stableStringify).join(",") + "]";
92
+ }
93
+ const keys = Object.keys(value as object).sort();
94
+ return (
95
+ "{" +
96
+ keys
97
+ .map(
98
+ (k) =>
99
+ JSON.stringify(k) +
100
+ ":" +
101
+ stableStringify((value as Record<string, unknown>)[k])
102
+ )
103
+ .join(",") +
104
+ "}"
105
+ );
106
+ }
107
+
108
+ /** TEXT for raw strings, JSON for everything else. */
109
+ function mimeTypeFor(value: unknown): MimeType {
110
+ return typeof value === "string" ? MimeType.TEXT : MimeType.JSON;
111
+ }
112
+
113
+ interface TaskSpanLifecycle {
114
+ span: Span;
115
+ traceId: string;
116
+ ended: boolean;
117
+ }
118
+
119
+ const taskSpansByRun = new WeakMap<RunState, TaskSpanLifecycle>();
120
+
121
+ /**
122
+ * Warn once when `PHOENIX_HOST` is plain `http:` while an `Authorization`
123
+ * header is being forwarded to the OTLP exporter — that combination
124
+ * exfiltrates the bearer token in cleartext.
125
+ */
126
+ let warnedAboutHttpScheme = false;
127
+ function maybeWarnHttpScheme(
128
+ baseUrl: string | undefined,
129
+ headers: PhoenixClient["config"]["headers"]
130
+ ): void {
131
+ if (warnedAboutHttpScheme || !baseUrl) return;
132
+ let parsed: URL;
133
+ try {
134
+ parsed = new URL(baseUrl);
135
+ } catch {
136
+ return;
137
+ }
138
+ if (parsed.protocol !== "http:") return;
139
+ if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")
140
+ return;
141
+ if (!headers) return;
142
+ const picked = toObjectHeaders(headers);
143
+ const hasAuth = Object.keys(picked).some(
144
+ (h) => h.toLowerCase() === "authorization"
145
+ );
146
+ if (!hasAuth) return;
147
+ warnedAboutHttpScheme = true;
148
+ // eslint-disable-next-line no-console
149
+ console.warn(
150
+ `[@arizeai/phoenix-client] PHOENIX_HOST="${baseUrl}" uses http:// with ` +
151
+ `an Authorization header set; the bearer token will travel in cleartext. ` +
152
+ `Use https:// for non-localhost Phoenix endpoints.`
153
+ );
154
+ }
155
+
156
+ function buildLinks(
157
+ client: PhoenixClient,
158
+ datasetId: string,
159
+ experimentId: string
160
+ ): Array<{ label: string; url: string }> {
161
+ const baseUrl = client.config.baseUrl;
162
+ if (!baseUrl) return [];
163
+ return [
164
+ { label: "Dataset", url: getDatasetUrl({ baseUrl, datasetId }) },
165
+ {
166
+ label: "Experiments",
167
+ url: getDatasetExperimentsUrl({ baseUrl, datasetId }),
168
+ },
169
+ {
170
+ label: "Experiment",
171
+ url: getExperimentUrl({ baseUrl, datasetId, experimentId }),
172
+ },
173
+ ];
174
+ }
175
+
176
+ /**
177
+ * Initialize the suite: upload the dataset, create the experiment, and
178
+ * register the OpenInference tracer.
179
+ *
180
+ * If tracing is disabled (no Phoenix env vars, or PHOENIX_TEST_TRACKING=false),
181
+ * this populates a no-op tracer and exits without making any network calls.
182
+ */
183
+ export async function initializeSuite(suite: SuiteState): Promise<void> {
184
+ const tracking = isTrackingEnabled(suite);
185
+ if (!tracking.enabled) {
186
+ suite.trackingDisabled = true;
187
+ suite.trackingDisabledReason = tracking.reason;
188
+ suite.tracer = createNoOpProvider().getTracer("no-op");
189
+ suite.evaluatorTracer = suite.tracer;
190
+ return;
191
+ }
192
+
193
+ const client = suite.config.client ?? createClient();
194
+ suite.client = client;
195
+
196
+ const datasetName = suite.config.datasetName ?? suite.name;
197
+ const description =
198
+ suite.config.description ??
199
+ `Phoenix test dataset auto-generated from ${suite.name}`;
200
+
201
+ const examples = Array.from(suite.registeredExamples.values()).map(
202
+ (registered) => ({
203
+ id: registered.params.id ?? null,
204
+ input: registered.params.input,
205
+ output: registered.params.expected ?? {},
206
+ metadata: registered.params.metadata ?? {},
207
+ splits: registered.params.splits,
208
+ })
209
+ );
210
+
211
+ let datasetId: string;
212
+ try {
213
+ const created = await createDataset({
214
+ client,
215
+ name: datasetName,
216
+ description,
217
+ examples,
218
+ });
219
+ datasetId = created.datasetId;
220
+ } catch (err) {
221
+ suite.trackingDisabled = true;
222
+ suite.setupError = err instanceof Error ? err : new Error(String(err));
223
+ suite.tracer = createNoOpProvider().getTracer("no-op");
224
+ suite.evaluatorTracer = suite.tracer;
225
+ return;
226
+ }
227
+ suite.datasetId = datasetId;
228
+
229
+ // Map test names to server-side example ids by re-fetching the dataset.
230
+ // The server doesn't promise that the GET response order matches the
231
+ // upload order, so we match by user-supplied `id` first, then by
232
+ // `JSON.stringify(input)` deep-equality with FIFO-on-collision.
233
+ try {
234
+ const { data: response } = await client.GET("/v1/datasets/{id}/examples", {
235
+ params: { path: { id: datasetId } },
236
+ });
237
+ const fetched = response?.data?.examples ?? [];
238
+
239
+ const idToTestName = new Map<string, string>();
240
+ const inputKeyToTestNames = new Map<string, string[]>();
241
+ for (const [testName, registered] of suite.registeredExamples.entries()) {
242
+ if (registered.params.id) {
243
+ idToTestName.set(registered.params.id, testName);
244
+ continue;
245
+ }
246
+ const key = stableKey(registered.params.input);
247
+ const arr = inputKeyToTestNames.get(key) ?? [];
248
+ arr.push(testName);
249
+ inputKeyToTestNames.set(key, arr);
250
+ }
251
+
252
+ for (const ex of fetched) {
253
+ const byId = idToTestName.get(ex.id);
254
+ if (byId) {
255
+ suite.exampleIdsByTest.set(byId, {
256
+ exampleId: ex.id,
257
+ nodeId: ex.node_id,
258
+ });
259
+ idToTestName.delete(ex.id);
260
+ continue;
261
+ }
262
+ const queue = inputKeyToTestNames.get(stableKey(ex.input));
263
+ if (queue && queue.length) {
264
+ const testName = queue.shift() as string;
265
+ suite.exampleIdsByTest.set(testName, {
266
+ exampleId: ex.id,
267
+ nodeId: ex.node_id,
268
+ });
269
+ }
270
+ }
271
+ } catch {
272
+ // If we cannot resolve example ids, runs will be logged without one.
273
+ }
274
+
275
+ const projectName = `${datasetName}-${new Date().toISOString()}`;
276
+ suite.projectName = projectName;
277
+
278
+ try {
279
+ const experimentResponse = await client
280
+ .POST("/v1/datasets/{dataset_id}/experiments", {
281
+ params: { path: { dataset_id: datasetId } },
282
+ body: {
283
+ name: suite.config.datasetName ?? suite.name,
284
+ description,
285
+ metadata: { ...(suite.config.metadata ?? {}), ...envMetadata() },
286
+ project_name: projectName,
287
+ repetitions: Math.max(1, suite.maxRepetitions ?? 1),
288
+ },
289
+ })
290
+ .then((res) => res.data?.data);
291
+ if (!experimentResponse) {
292
+ throw new Error("Failed to create experiment");
293
+ }
294
+ suite.experimentId = experimentResponse.id;
295
+ suite.projectName = experimentResponse.project_name ?? projectName;
296
+ } catch (err) {
297
+ suite.trackingDisabled = true;
298
+ suite.setupError = err instanceof Error ? err : new Error(String(err));
299
+ suite.tracer = createNoOpProvider().getTracer("no-op");
300
+ suite.evaluatorTracer = suite.tracer;
301
+ return;
302
+ }
303
+
304
+ const baseUrl = client.config.baseUrl;
305
+ if (!baseUrl) {
306
+ suite.trackingDisabled = true;
307
+ suite.setupError = new Error(
308
+ "Phoenix base URL not found. Set PHOENIX_HOST or pass baseUrl on the client."
309
+ );
310
+ suite.tracer = createNoOpProvider().getTracer("no-op");
311
+ suite.evaluatorTracer = suite.tracer;
312
+ return;
313
+ }
314
+
315
+ maybeWarnHttpScheme(baseUrl, client.config.headers);
316
+
317
+ let provider: NodeTracerProvider;
318
+ try {
319
+ provider = register({
320
+ projectName: suite.projectName,
321
+ url: baseUrl,
322
+ headers: client.config.headers
323
+ ? toObjectHeaders(client.config.headers)
324
+ : undefined,
325
+ batch: false,
326
+ global: false,
327
+ });
328
+ suite.tracerProvider = provider;
329
+ suite.globalRegistration = attachGlobalTracerProvider(provider);
330
+ } catch (err) {
331
+ suite.trackingDisabled = true;
332
+ suite.setupError = err instanceof Error ? err : new Error(String(err));
333
+ suite.tracer = createNoOpProvider().getTracer("no-op");
334
+ suite.evaluatorTracer = suite.tracer;
335
+ return;
336
+ }
337
+ suite.tracer = provider.getTracer(suite.projectName);
338
+ suite.evaluatorTracer = provider.getTracer(`${suite.projectName}-evaluators`);
339
+
340
+ if (suite.datasetId && suite.experimentId) {
341
+ suite.links = buildLinks(client, suite.datasetId, suite.experimentId);
342
+ }
343
+ }
344
+
345
+ /** Lazily-created no-op tracer reused for dry runs / fallbacks. */
346
+ let noOpTracer: Tracer | undefined;
347
+ function getNoOpTracer(): Tracer {
348
+ if (!noOpTracer) noOpTracer = createNoOpProvider().getTracer("no-op");
349
+ return noOpTracer;
350
+ }
351
+
352
+ /** The tracer to use for the currently-running test — no-op when dry. */
353
+ function taskTracer(suite: SuiteState): Tracer {
354
+ if (currentRun()?.dryRun) return getNoOpTracer();
355
+ return suite.tracer ?? getNoOpTracer();
356
+ }
357
+
358
+ /**
359
+ * Wrap the user's test body in an OpenInference task span and return the
360
+ * trace id so we can submit it with the experiment run.
361
+ */
362
+ export async function runTaskWithTracing<Result>(
363
+ suite: SuiteState,
364
+ testName: string,
365
+ fn: () => Promise<Result>
366
+ ): Promise<
367
+ | { traceId: string; result: Result }
368
+ | { traceId: string; error: Error; isTaskError: boolean }
369
+ > {
370
+ const tracer: Tracer = taskTracer(suite);
371
+ return tracer.startActiveSpan(`Test: ${testName}`, async (span) => {
372
+ const traceId = span.spanContext().traceId;
373
+ const run = currentRun();
374
+ if (run) {
375
+ run.traceId = traceId;
376
+ taskSpansByRun.set(run, { span, traceId, ended: false });
377
+ }
378
+ try {
379
+ const result = await fn();
380
+ if (run) {
381
+ endTaskSpan({ run, fallbackOutput: result });
382
+ } else {
383
+ endSpanAsTask({ span, input: undefined, output: result });
384
+ }
385
+ return { traceId, result };
386
+ } catch (err) {
387
+ const error = err instanceof Error ? err : new Error(String(err));
388
+ const isTaskError = run ? !hasTaskSpanEnded(run) : true;
389
+ if (run && isTaskError) {
390
+ endTaskSpan({ run, error });
391
+ } else if (!run) {
392
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
393
+ span.end();
394
+ }
395
+ return { traceId, error, isTaskError };
396
+ } finally {
397
+ if (run) {
398
+ taskSpansByRun.delete(run);
399
+ }
400
+ }
401
+ });
402
+ }
403
+
404
+ function hasTaskSpanEnded(run: RunState): boolean {
405
+ return taskSpansByRun.get(run)?.ended ?? false;
406
+ }
407
+
408
+ /**
409
+ * End the current run's task span. `logOutput()` calls this immediately so
410
+ * evaluator work that follows is not included in the task span duration.
411
+ */
412
+ export function endTaskSpanForRun(run: RunState): void {
413
+ endTaskSpan({ run });
414
+ }
415
+
416
+ function endTaskSpan({
417
+ run,
418
+ fallbackOutput,
419
+ error,
420
+ }: {
421
+ run: RunState;
422
+ fallbackOutput?: unknown;
423
+ error?: Error;
424
+ }): void {
425
+ const lifecycle = taskSpansByRun.get(run);
426
+ if (!lifecycle || lifecycle.ended) return;
427
+ const output = run.outputSet ? run.output : fallbackOutput;
428
+ if (error) {
429
+ lifecycle.span.setStatus({
430
+ code: SpanStatusCode.ERROR,
431
+ message: error.message,
432
+ });
433
+ lifecycle.span.setAttributes({
434
+ [SemanticConventions.OPENINFERENCE_SPAN_KIND]:
435
+ OpenInferenceSpanKind.CHAIN,
436
+ [SemanticConventions.INPUT_MIME_TYPE]: mimeTypeFor(run.params.input),
437
+ [SemanticConventions.INPUT_VALUE]: ensureString(run.params.input),
438
+ });
439
+ lifecycle.span.end();
440
+ } else {
441
+ endSpanAsTask({ span: lifecycle.span, input: run.params.input, output });
442
+ }
443
+ run.traceId = lifecycle.traceId;
444
+ run.taskEndTime = new Date();
445
+ lifecycle.ended = true;
446
+ }
447
+
448
+ function endSpanAsTask({
449
+ span,
450
+ input,
451
+ output,
452
+ }: {
453
+ span: Span;
454
+ input: unknown;
455
+ output: unknown;
456
+ }): void {
457
+ span.setAttributes({
458
+ [SemanticConventions.OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.CHAIN,
459
+ [SemanticConventions.INPUT_MIME_TYPE]: mimeTypeFor(input),
460
+ [SemanticConventions.INPUT_VALUE]: ensureString(input),
461
+ [SemanticConventions.OUTPUT_MIME_TYPE]: mimeTypeFor(output),
462
+ [SemanticConventions.OUTPUT_VALUE]: ensureString(output),
463
+ });
464
+ span.setStatus({ code: SpanStatusCode.OK });
465
+ span.end();
466
+ }
467
+
468
+ /**
469
+ * POST a single experiment run for one test case to Phoenix.
470
+ *
471
+ * Best-effort: failures are captured and surfaced via the test reporter but
472
+ * do not fail the test itself.
473
+ */
474
+ export async function postExperimentRun(
475
+ suite: SuiteState,
476
+ run: RunState
477
+ ): Promise<string | undefined> {
478
+ if (
479
+ run.dryRun ||
480
+ suite.trackingDisabled ||
481
+ !suite.client ||
482
+ !suite.experimentId
483
+ ) {
484
+ return undefined;
485
+ }
486
+ const example = suite.exampleIdsByTest.get(run.logicalName);
487
+ if (!example) {
488
+ return undefined;
489
+ }
490
+ try {
491
+ const res = await suite.client.POST(
492
+ "/v1/experiments/{experiment_id}/runs",
493
+ {
494
+ params: { path: { experiment_id: suite.experimentId } },
495
+ body: {
496
+ dataset_example_id: example.nodeId,
497
+ output: run.outputSet
498
+ ? (run.output as Record<string, unknown> | string | null)
499
+ : null,
500
+ repetition_number: run.repetitionNumber,
501
+ start_time: run.startTime.toISOString(),
502
+ end_time: (
503
+ run.taskEndTime ??
504
+ run.endTime ??
505
+ new Date()
506
+ ).toISOString(),
507
+ error: run.error ?? null,
508
+ trace_id: run.traceId ?? null,
509
+ },
510
+ }
511
+ );
512
+ return res.data?.data.id;
513
+ } catch {
514
+ suite.uploadFailureCount = (suite.uploadFailureCount ?? 0) + 1;
515
+ return undefined;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * POST one annotation (an "experiment_evaluation") for a run.
521
+ */
522
+ export async function postAnnotation(
523
+ suite: SuiteState,
524
+ runId: string | undefined,
525
+ annotation: Annotation
526
+ ): Promise<void> {
527
+ if (suite.trackingDisabled || !suite.client || !runId) return;
528
+ const start = new Date();
529
+ const end = new Date();
530
+ try {
531
+ await suite.client.POST("/v1/experiment_evaluations", {
532
+ body: {
533
+ experiment_run_id: runId,
534
+ name: annotation.name,
535
+ annotator_kind: annotation.annotatorKind ?? "CODE",
536
+ start_time: start.toISOString(),
537
+ end_time: end.toISOString(),
538
+ result: {
539
+ score:
540
+ typeof annotation.score === "boolean"
541
+ ? annotation.score
542
+ ? 1
543
+ : 0
544
+ : (annotation.score ?? null),
545
+ label: annotation.label ?? null,
546
+ explanation: annotation.explanation ?? null,
547
+ // Forward annotation metadata, matching the experiment evaluator
548
+ // upload path in `runExperiment`. Spread so it's omitted when unset.
549
+ ...(annotation.metadata ? { metadata: annotation.metadata } : {}),
550
+ },
551
+ error: null,
552
+ trace_id: annotation.traceId ?? null,
553
+ },
554
+ });
555
+ } catch {
556
+ suite.uploadFailureCount = (suite.uploadFailureCount ?? 0) + 1;
557
+ }
558
+ }
559
+
560
+ /** Run an evaluator in an OpenInference evaluator span. */
561
+ export async function runEvaluatorWithTracing<
562
+ EvaluatorParams extends KVMap,
563
+ EvaluatorResult,
564
+ >(
565
+ suite: SuiteState,
566
+ name: string,
567
+ params: EvaluatorParams,
568
+ fn: (params: EvaluatorParams) => EvaluatorResult | Promise<EvaluatorResult>
569
+ ): Promise<{ result: EvaluatorResult; traceId: string | null }> {
570
+ const isDryRun = currentRun()?.dryRun ?? false;
571
+ const tracer: Tracer = isDryRun
572
+ ? getNoOpTracer()
573
+ : (suite.evaluatorTracer ?? suite.tracer ?? getNoOpTracer());
574
+ const parentlessContext = trace.deleteSpan(context.active());
575
+ const evaluatorContext = isDryRun
576
+ ? suppressTracing(parentlessContext)
577
+ : parentlessContext;
578
+ return context.with(evaluatorContext, () =>
579
+ tracer.startActiveSpan(`Evaluation: ${name}`, async (span) => {
580
+ const traceId = isDryRun ? null : span.spanContext().traceId;
581
+ try {
582
+ const result = await fn(params);
583
+ span.setAttributes({
584
+ [SemanticConventions.OPENINFERENCE_SPAN_KIND]:
585
+ OpenInferenceSpanKind.EVALUATOR,
586
+ [SemanticConventions.INPUT_MIME_TYPE]: MimeType.JSON,
587
+ [SemanticConventions.INPUT_VALUE]: ensureString(params),
588
+ [SemanticConventions.OUTPUT_MIME_TYPE]: MimeType.JSON,
589
+ [SemanticConventions.OUTPUT_VALUE]: ensureString(result),
590
+ });
591
+ span.setStatus({ code: SpanStatusCode.OK });
592
+ return { result, traceId };
593
+ } catch (err) {
594
+ const error = err instanceof Error ? err : new Error(String(err));
595
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
596
+ throw error;
597
+ } finally {
598
+ span.end();
599
+ }
600
+ })
601
+ );
602
+ }
603
+
604
+ /**
605
+ * Tear down the tracer provider created for the suite, flushing any pending
606
+ * spans before the process exits.
607
+ */
608
+ export async function teardownSuite(suite: SuiteState): Promise<void> {
609
+ const provider = suite.tracerProvider;
610
+ if (!provider) return;
611
+ try {
612
+ await cleanupOwnedTracerProvider({
613
+ provider,
614
+ globalRegistration: suite.globalRegistration,
615
+ });
616
+ } finally {
617
+ suite.tracerProvider = undefined;
618
+ suite.globalRegistration = null;
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Snapshot environment-derived metadata recorded on the experiment so users
624
+ * can filter experiments by env in the Phoenix UI.
625
+ */
626
+ function envMetadata(): KVMap {
627
+ const out: KVMap = {};
628
+ const env =
629
+ process.env.PHOENIX_ENVIRONMENT ??
630
+ process.env.ENVIRONMENT ??
631
+ process.env.NODE_ENV;
632
+ if (env) out.environment = env;
633
+ return out;
634
+ }
635
+
636
+ /** Re-export the ones the entrypoint files consume. */
637
+ export type { GlobalTracerProviderRegistration, NodeTracerProvider };