UNPKG

@arizeai/phoenix-client

Version:
352 lines (316 loc) 10.3 kB
import type { paths } from "../__generated__/api/v1"; import { createClient } from "../client"; import { HttpError } from "../errors"; import type { ClientFn } from "../types/core"; import type { ProjectIdentifier } from "../types/projects"; import { resolveProjectIdentifier } from "../types/projects"; import { formatApiError } from "../utils/apiErrorUtils"; import { isObject } from "../utils/isObject"; import { safelyParseJSON } from "../utils/safelyParseJSON"; type CreateSpansRequestData = paths["/v1/projects/{project_identifier}/spans"]["post"]["requestBody"]["content"]["application/json"]["data"]; /** * A span in Phoenix's simplified span structure, as accepted by {@link logSpans}. * This is the same shape returned by `getSpans`, which makes it possible to read * spans from one project and log them into another. */ export type Span = CreateSpansRequestData[number]; /** Information about a span that failed validation and was not queued. */ export interface InvalidSpanInfo { spanId: string; traceId: string; error: string; } /** Information about a span that was rejected because it already exists. */ export interface DuplicateSpanInfo { spanId: string; traceId: string; } /** * Raised by {@link logSpans} when one or more spans in the request are invalid * or duplicates. If any span in a request fails, none of the spans in that * request are queued. */ export class SpanCreationError extends Error { readonly invalidSpans: InvalidSpanInfo[]; readonly duplicateSpans: DuplicateSpanInfo[]; readonly totalReceived: number; readonly totalQueued: number; constructor(params: { message: string; invalidSpans?: InvalidSpanInfo[]; duplicateSpans?: DuplicateSpanInfo[]; totalReceived?: number; totalQueued?: number; }) { super(params.message); this.name = "SpanCreationError"; this.invalidSpans = params.invalidSpans ?? []; this.duplicateSpans = params.duplicateSpans ?? []; this.totalReceived = params.totalReceived ?? 0; this.totalQueued = params.totalQueued ?? 0; } /** Number of spans rejected as invalid. */ get totalInvalid(): number { return this.invalidSpans.length; } /** Number of spans rejected as duplicates. */ get totalDuplicates(): number { return this.duplicateSpans.length; } } /** * Parameters to log spans to a project */ export interface LogSpansParams extends ClientFn { /** The project to log spans into */ project: ProjectIdentifier; /** The spans to log */ spans: Span[]; } /** * Statistics about a {@link logSpans} call. When successful, `totalQueued` * equals `totalReceived`. */ export interface LogSpansResult { totalReceived: number; totalQueued: number; } const MAX_ERRORS_TO_SHOW = 5; function formatLogSpansErrorMessage({ invalidSpans, duplicateSpans, }: { invalidSpans: InvalidSpanInfo[]; duplicateSpans: DuplicateSpanInfo[]; }): string { const parts: string[] = []; if (invalidSpans.length > 0) { parts.push(`Failed to queue ${invalidSpans.length} invalid spans:`); for (const span of invalidSpans.slice(0, MAX_ERRORS_TO_SHOW)) { parts.push(` - Span ${span.spanId}: ${span.error}`); } if (invalidSpans.length > MAX_ERRORS_TO_SHOW) { parts.push( ` ... and ${invalidSpans.length - MAX_ERRORS_TO_SHOW} more invalid spans` ); } } if (duplicateSpans.length > 0) { if (parts.length > 0) parts.push(""); parts.push(`Found ${duplicateSpans.length} duplicate spans:`); for (const span of duplicateSpans.slice(0, MAX_ERRORS_TO_SHOW)) { parts.push(` - Span ${span.spanId}`); } if (duplicateSpans.length > MAX_ERRORS_TO_SHOW) { parts.push( ` ... and ${duplicateSpans.length - MAX_ERRORS_TO_SHOW} more duplicates` ); } } return parts.join("\n"); } function toSpanIdAndTraceId(item: unknown): { spanId: string; traceId: string; } { const record = isObject(item) ? (item as Record<string, unknown>) : {}; return { spanId: typeof record.span_id === "string" ? record.span_id : "unknown", traceId: typeof record.trace_id === "string" ? record.trace_id : "unknown", }; } function toInvalidSpanInfoList(value: unknown): InvalidSpanInfo[] { if (!Array.isArray(value)) return []; return value.map((item) => { const record = isObject(item) ? (item as Record<string, unknown>) : {}; return { ...toSpanIdAndTraceId(item), error: typeof record.error === "string" ? record.error : "Validation error", }; }); } function toDuplicateSpanInfoList(value: unknown): DuplicateSpanInfo[] { if (!Array.isArray(value)) return []; return value.map(toSpanIdAndTraceId); } /** * Builds a {@link SpanCreationError} from already-normalized invalid/duplicate * span lists, computing the shared message once. */ function makeSpanCreationError(params: { invalidSpans: InvalidSpanInfo[]; duplicateSpans: DuplicateSpanInfo[]; totalReceived: number; totalQueued: number; }): SpanCreationError { return new SpanCreationError({ message: formatLogSpansErrorMessage(params), ...params, }); } /** * Builds a {@link SpanCreationError} from a parsed error payload matching the * shape returned by the server for invalid/duplicate spans: * `{ total_received, total_queued, total_invalid, total_duplicates, invalid_spans, duplicate_spans }`. */ function buildSpanCreationError( payload: Record<string, unknown> ): SpanCreationError { return makeSpanCreationError({ invalidSpans: toInvalidSpanInfoList(payload.invalid_spans), duplicateSpans: toDuplicateSpanInfoList(payload.duplicate_spans), totalReceived: typeof payload.total_received === "number" ? payload.total_received : 0, totalQueued: typeof payload.total_queued === "number" ? payload.total_queued : 0, }); } function isSpanCreationErrorPayload( value: unknown ): value is Record<string, unknown> { return ( isObject(value) && ("invalid_spans" in value || "duplicate_spans" in value) ); } /** * Extracts invalid span info from a FastAPI request-validation error array * (returned with a 422 status when a span in the payload is malformed). */ function extractInvalidSpansFromValidationErrors( errors: unknown[], spans: Span[] ): InvalidSpanInfo[] { const invalidSpans: InvalidSpanInfo[] = []; for (const rawError of errors) { if (!isObject(rawError)) continue; const loc = (rawError as { loc?: unknown }).loc; if ( !Array.isArray(loc) || loc.length < 3 || loc[0] !== "body" || loc[1] !== "data" || typeof loc[2] !== "number" ) { continue; } const span = spans[loc[2]]; if (!span) continue; const msg = (rawError as { msg?: unknown }).msg; invalidSpans.push({ spanId: span.context?.span_id ?? "unknown", traceId: span.context?.trace_id ?? "unknown", error: typeof msg === "string" ? msg : "Validation error", }); } return invalidSpans; } function parseLogSpansError(error: unknown, spans: Span[]): Error { if (isObject(error)) { const detail = (error as { detail?: unknown }).detail; if (typeof detail === "string") { const { json: parsed } = safelyParseJSON(detail); if (isSpanCreationErrorPayload(parsed)) { return buildSpanCreationError(parsed); } } else if (Array.isArray(detail)) { const invalidSpans = extractInvalidSpansFromValidationErrors( detail, spans ); if (invalidSpans.length > 0) { return makeSpanCreationError({ invalidSpans, duplicateSpans: [], totalReceived: spans.length, // A FastAPI 422 rejects the entire request body, so no spans are // queued — matching the all-or-nothing contract and the 400 path. totalQueued: 0, }); } } else if (isSpanCreationErrorPayload(error)) { return buildSpanCreationError(error); } } return new Error(`Failed to log spans: ${formatApiError(error)}`); } async function parseLogSpansHttpError( error: HttpError, spans: Span[] ): Promise<Error> { try { const contentType = error.response.headers.get("content-type"); const payload: unknown = contentType?.includes("application/json") ? await error.response.json() : await error.response.text(); return parseLogSpansError(payload, spans); } catch { return parseLogSpansError(error, spans); } } /** * Log spans to a project using Phoenix's simplified span structure. * * If any span in the request is invalid or a duplicate of a span that already * exists, none of the spans in the request are queued and a * {@link SpanCreationError} is thrown with details about the failures. * * @experimental this function is experimental and may change in the future * * @param params - The parameters to log spans * @returns Statistics about the operation. When successful, `totalQueued` * equals `totalReceived`. * * @example * ```ts * const result = await logSpans({ * project: { projectName: "my-project" }, * spans: [ * { * name: "test", * context: { trace_id: "123", span_id: "456" }, * span_kind: "CHAIN", * start_time: "2024-01-01T00:00:00Z", * end_time: "2024-01-01T00:00:01Z", * status_code: "OK", * }, * ], * }); * console.log(`Queued ${result.totalQueued} spans`); * ``` */ export async function logSpans({ client: _client, project, spans, }: LogSpansParams): Promise<LogSpansResult> { const client = _client ?? createClient(); const projectIdentifier = resolveProjectIdentifier(project); let response: Awaited<ReturnType<typeof client.POST>>; try { response = await client.POST("/v1/projects/{project_identifier}/spans", { params: { path: { project_identifier: projectIdentifier, }, }, body: { data: spans, }, }); } catch (error) { if (error instanceof HttpError) { throw await parseLogSpansHttpError(error, spans); } throw error; } const { data, error } = response; if (error) { throw parseLogSpansError(error, spans); } return { totalReceived: data?.total_received ?? spans.length, totalQueued: data?.total_queued ?? 0, }; }