@arizeai/phoenix-client
Version:
A client for the Phoenix API
352 lines (316 loc) • 10.3 kB
text/typescript
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,
};
}