@arizeai/phoenix-client
Version:
A client for the Phoenix API
239 lines • 8.53 kB
JavaScript
import { createClient } from "../client.js";
import { HttpError } from "../errors.js";
import { resolveProjectIdentifier } from "../types/projects.js";
import { formatApiError } from "../utils/apiErrorUtils.js";
import { isObject } from "../utils/isObject.js";
import { safelyParseJSON } from "../utils/safelyParseJSON.js";
/**
* 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 {
invalidSpans;
duplicateSpans;
totalReceived;
totalQueued;
constructor(params) {
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() {
return this.invalidSpans.length;
}
/** Number of spans rejected as duplicates. */
get totalDuplicates() {
return this.duplicateSpans.length;
}
}
const MAX_ERRORS_TO_SHOW = 5;
function formatLogSpansErrorMessage({ invalidSpans, duplicateSpans, }) {
const parts = [];
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) {
const record = isObject(item) ? item : {};
return {
spanId: typeof record.span_id === "string" ? record.span_id : "unknown",
traceId: typeof record.trace_id === "string" ? record.trace_id : "unknown",
};
}
function toInvalidSpanInfoList(value) {
if (!Array.isArray(value))
return [];
return value.map((item) => {
const record = isObject(item) ? item : {};
return {
...toSpanIdAndTraceId(item),
error: typeof record.error === "string" ? record.error : "Validation error",
};
});
}
function toDuplicateSpanInfoList(value) {
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) {
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) {
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) {
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, spans) {
const invalidSpans = [];
for (const rawError of errors) {
if (!isObject(rawError))
continue;
const loc = rawError.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.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, spans) {
if (isObject(error)) {
const detail = error.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, spans) {
try {
const contentType = error.response.headers.get("content-type");
const payload = 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, }) {
const client = _client ?? createClient();
const projectIdentifier = resolveProjectIdentifier(project);
let response;
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,
};
}
//# sourceMappingURL=logSpans.js.map