diff --git a/.changeset/public-trace-api.md b/.changeset/public-trace-api.md new file mode 100644 index 000000000..fbffb4b19 --- /dev/null +++ b/.changeset/public-trace-api.md @@ -0,0 +1,6 @@ +--- +"@voltagent/core": patch +"@voltagent/sdk": patch +--- + +Add a VoltOps observability trace list API for loading persisted traces with project keys. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42c239ef2..e3a382259 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,13 @@ export { trace, context, } from "./observability"; +export type { + VoltOpsObservabilityApi, + VoltOpsObservabilityTrace, + VoltOpsTraceListOptions, + VoltOpsTraceListResponse, + VoltOpsTraceSortOrder, +} from "./voltops"; export { TRIGGER_CONTEXT_KEY } from "./observability/context-keys"; export { SERVERLESS_ENV_CONTEXT_KEY } from "./context-keys"; export { createTriggers } from "./triggers/dsl"; diff --git a/packages/core/src/voltops/client.spec.ts b/packages/core/src/voltops/client.spec.ts index a2fa98f26..c6fd8fe33 100644 --- a/packages/core/src/voltops/client.spec.ts +++ b/packages/core/src/voltops/client.spec.ts @@ -354,6 +354,46 @@ describe("VoltOpsClient", () => { }); }); + describe("observability client", () => { + it("lists traces with API keys, filters, and pagination", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { trace_id: "trace-1", project_id: "project-1", start_time: "2026-04-27T00:00:00Z" }, + ], + total: 1, + pageCount: 1, + }), + }); + const observabilityClient = new VoltOpsClient({ + ...mockOptions, + fetch: fetchMock as unknown as typeof fetch, + }); + + const result = await observabilityClient.observability.traces.list({ + limit: 10, + offset: 20, + search: "checkout", + environments: ["dev", "prod"], + sortOrder: "desc", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.voltops.com/api/public/otel/v1/traces?limit=10&offset=20&search=checkout&environments=dev%2Cprod&sortOrder=desc", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "X-Public-Key": "pk_test_key", + "X-Secret-Key": "sk_test_key", + }), + body: undefined, + }), + ); + expect(result.total).toBe(1); + }); + }); + describe("actions client", () => { const originalFetch = globalThis.fetch; let fetchMock: ReturnType; diff --git a/packages/core/src/voltops/client.ts b/packages/core/src/voltops/client.ts index dd58110a9..995885088 100644 --- a/packages/core/src/voltops/client.ts +++ b/packages/core/src/voltops/client.ts @@ -61,8 +61,11 @@ import type { VoltOpsFeedbackCreateInput, VoltOpsFeedbackToken, VoltOpsFeedbackTokenCreateInput, + VoltOpsObservabilityApi, VoltOpsPromptManager, VoltOpsScorerSummary, + VoltOpsTraceListOptions, + VoltOpsTraceListResponse, } from "./types"; /** @@ -76,6 +79,7 @@ export class VoltOpsClient implements IVoltOpsClient { public readonly managedMemory: ManagedMemoryVoltOpsClient; public readonly actions: VoltOpsActionsClient; public readonly evals: VoltOpsEvalsApi; + public readonly observability: VoltOpsObservabilityApi; private readonly logger: Logger; private get fetchImpl(): typeof fetch { @@ -115,6 +119,11 @@ export class VoltOpsClient implements IVoltOpsClient { create: this.createEvalScorer.bind(this), }, }; + this.observability = { + traces: { + list: this.listObservabilityTraces.bind(this), + }, + }; // Check if keys are valid (not empty and have correct prefixes) const hasValidKeys = @@ -381,6 +390,16 @@ export class VoltOpsClient implements IVoltOpsClient { return this.normalizeScorerSummary(response); } + private async listObservabilityTraces( + options: VoltOpsTraceListOptions = {}, + ): Promise { + const query = this.buildQueryString(options as Record); + return await this.request( + "GET", + `/api/public/otel/v1/traces${query}`, + ); + } + private async request(method: string, endpoint: string, body?: unknown): Promise { const url = `${this.options.baseUrl.replace(/\/$/, "")}${endpoint}`; const headers: Record = { diff --git a/packages/core/src/voltops/index.ts b/packages/core/src/voltops/index.ts index cc0e8212d..f6d6e6134 100644 --- a/packages/core/src/voltops/index.ts +++ b/packages/core/src/voltops/index.ts @@ -41,6 +41,11 @@ export type { VoltOpsFeedbackExpiresIn, VoltOpsFeedbackToken, VoltOpsFeedbackTokenCreateInput, + VoltOpsObservabilityApi, + VoltOpsObservabilityTrace, + VoltOpsTraceListOptions, + VoltOpsTraceListResponse, + VoltOpsTraceSortOrder, VoltOpsActionExecutionResult, VoltOpsAirtableCreateRecordParams, VoltOpsAirtableUpdateRecordParams, diff --git a/packages/core/src/voltops/types.ts b/packages/core/src/voltops/types.ts index 4130c6d35..8eb0608ee 100644 --- a/packages/core/src/voltops/types.ts +++ b/packages/core/src/voltops/types.ts @@ -956,6 +956,87 @@ export interface VoltOpsScorerSummary { updatedAt: string; } +export type VoltOpsTraceSortOrder = "asc" | "desc"; + +export interface VoltOpsTraceListOptions { + agentId?: string | string[]; + model?: string | string[]; + traceId?: string; + limit?: number; + offset?: number; + sortBy?: string; + sortOrder?: VoltOpsTraceSortOrder; + environments?: string | string[]; + status?: string | string[]; + level?: string | string[]; + tags?: string | string[]; + userId?: string; + startDate?: string | Date; + endDate?: string | Date; + minTokens?: number; + maxTokens?: number; + minCost?: number; + maxCost?: number; + minDuration?: number; + maxDuration?: number; + search?: string; + conversationId?: string; + entityType?: string | string[]; + promptId?: string; + promptVersion?: string; + feedbackKey?: string | string[]; + feedbackScore?: number; + minFeedbackScore?: number; + maxFeedbackScore?: number; + feedbackValue?: string; + feedbackSourceType?: string | string[]; +} + +export interface VoltOpsObservabilityTrace { + trace_id: string; + project_id: string; + agent_id?: string; + entity_type?: string; + user_id?: string | null; + conversation_id?: string | null; + start_time: string; + end_time?: string | null; + status?: string | null; + input?: unknown; + output?: unknown; + usage?: unknown; + metadata?: Record | null; + tags?: string[] | null; + model?: string | null; + level?: string | null; + status_message?: string | null; + service_name?: string; + service_version?: string | null; + span_count?: number; + error_count?: number; + duration_ms?: number | null; + resource_attributes?: Record | null; + created_at?: string; + latest_feedback_score?: number | null; + latest_feedback_key?: string | null; + latest_feedback_comment?: string | null; + latest_feedback_at?: string | null; + [key: string]: unknown; +} + +export interface VoltOpsTraceListResponse { + data: VoltOpsObservabilityTrace[]; + total: number; + pageCount: number; + subscription?: unknown; +} + +export interface VoltOpsObservabilityApi { + traces: { + list(options?: VoltOpsTraceListOptions): Promise; + }; +} + /** * Main VoltOps client interface */ @@ -972,6 +1053,9 @@ export interface VoltOpsClient { /** Evaluations API surface */ evals: VoltOpsEvalsApi; + /** Observability read API surface */ + observability: VoltOpsObservabilityApi; + /** Create a feedback token for the given trace */ createFeedbackToken(input: VoltOpsFeedbackTokenCreateInput): Promise; diff --git a/packages/sdk/src/client/index.spec.ts b/packages/sdk/src/client/index.spec.ts index d8033b678..abec6ac5c 100644 --- a/packages/sdk/src/client/index.spec.ts +++ b/packages/sdk/src/client/index.spec.ts @@ -347,4 +347,41 @@ describe("VoltAgentCoreAPI", () => { expect(response).toEqual(apiResponse); }); }); + + describe("observability traces", () => { + it("requests traces with search and pagination filters", async () => { + const apiResponse = { + data: [ + { + trace_id: "trace-1", + project_id: "project-1", + start_time: "2026-04-27T00:00:00Z", + }, + ], + total: 1, + pageCount: 1, + }; + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => apiResponse, + }); + + const api = new VoltAgentCoreAPI(defaultOptions); + const response = await api.observability.traces.list({ + limit: 25, + offset: 50, + search: "workflow", + environments: ["dev", "prod"], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.voltagent.dev/api/public/otel/v1/traces?limit=25&offset=50&search=workflow&environments=dev%2Cprod", + expect.objectContaining({ method: "GET" }), + ); + expect(response).toEqual(apiResponse); + }); + }); }); diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index a2b100de8..82d4b80b7 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -19,6 +19,9 @@ import type { ListEvalDatasetItemsOptions, ListEvalExperimentsOptions, VoltAgentClientOptions, + VoltOpsObservabilityApi, + VoltOpsTraceListOptions, + VoltOpsTraceListResponse, } from "../types"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -80,6 +83,7 @@ export class VoltAgentCoreAPI { private readonly timeout: number; public readonly actions: VoltOpsActionsClient; public readonly evals: VoltAgentEvalsAPI; + public readonly observability: VoltOpsObservabilityApi; constructor(options: VoltAgentClientOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_API_BASE_URL).trim(); @@ -120,6 +124,11 @@ export class VoltAgentCoreAPI { create: this.createEvalExperiment.bind(this), }, }; + this.observability = { + traces: { + list: this.listObservabilityTraces.bind(this), + }, + }; } private async fetchWithTimeout(url: string, init: RequestInit): Promise { @@ -177,6 +186,30 @@ export class VoltAgentCoreAPI { return data as T; } + private buildQueryString(params: Record): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + continue; + } + searchParams.set(key, value.join(",")); + } else if (value instanceof Date) { + searchParams.set(key, value.toISOString()); + } else { + searchParams.set(key, String(value)); + } + } + + const query = searchParams.toString(); + return query ? `?${query}` : ""; + } + public async sendRequest(path: string, init?: RequestInit): Promise { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = `${this.baseUrl}${normalizedPath}`; @@ -199,6 +232,13 @@ export class VoltAgentCoreAPI { }); } + private async listObservabilityTraces( + options: VoltOpsTraceListOptions = {}, + ): Promise { + const query = this.buildQueryString(options as Record); + return await this.request(`/api/public/otel/v1/traces${query}`); + } + private async appendEvalResults( runId: string, payload: AppendEvalRunResultsRequest, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 831a789e6..1bf89ed95 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -38,6 +38,11 @@ export type { RagSearchKnowledgeBaseRequest, RagSearchKnowledgeBaseResponse, RagSearchKnowledgeBaseResult, + VoltOpsObservabilityApi, + VoltOpsObservabilityTrace, + VoltOpsTraceListOptions, + VoltOpsTraceListResponse, + VoltOpsTraceSortOrder, } from "./types"; export type { VoltOpsActionExecutionResult, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 1bc49a045..2b107c44c 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -21,6 +21,87 @@ export type { RagSearchKnowledgeBaseResult, } from "@voltagent/core"; +export type VoltOpsTraceSortOrder = "asc" | "desc"; + +export interface VoltOpsTraceListOptions { + agentId?: string | string[]; + model?: string | string[]; + traceId?: string; + limit?: number; + offset?: number; + sortBy?: string; + sortOrder?: VoltOpsTraceSortOrder; + environments?: string | string[]; + status?: string | string[]; + level?: string | string[]; + tags?: string | string[]; + userId?: string; + startDate?: string | Date; + endDate?: string | Date; + minTokens?: number; + maxTokens?: number; + minCost?: number; + maxCost?: number; + minDuration?: number; + maxDuration?: number; + search?: string; + conversationId?: string; + entityType?: string | string[]; + promptId?: string; + promptVersion?: string; + feedbackKey?: string | string[]; + feedbackScore?: number; + minFeedbackScore?: number; + maxFeedbackScore?: number; + feedbackValue?: string; + feedbackSourceType?: string | string[]; +} + +export interface VoltOpsObservabilityTrace { + trace_id: string; + project_id: string; + agent_id?: string; + entity_type?: string; + user_id?: string | null; + conversation_id?: string | null; + start_time: string; + end_time?: string | null; + status?: string | null; + input?: unknown; + output?: unknown; + usage?: unknown; + metadata?: Record | null; + tags?: string[] | null; + model?: string | null; + level?: string | null; + status_message?: string | null; + service_name?: string; + service_version?: string | null; + span_count?: number; + error_count?: number; + duration_ms?: number | null; + resource_attributes?: Record | null; + created_at?: string; + latest_feedback_score?: number | null; + latest_feedback_key?: string | null; + latest_feedback_comment?: string | null; + latest_feedback_at?: string | null; + [key: string]: unknown; +} + +export interface VoltOpsTraceListResponse { + data: VoltOpsObservabilityTrace[]; + total: number; + pageCount: number; + subscription?: unknown; +} + +export interface VoltOpsObservabilityApi { + traces: { + list(options?: VoltOpsTraceListOptions): Promise; + }; +} + export type EvalRunStatus = "pending" | "running" | "succeeded" | "failed" | "cancelled"; export type TerminalEvalRunStatus = "succeeded" | "failed" | "cancelled"; export type EvalResultStatus = "pending" | "running" | "passed" | "failed" | "error"; diff --git a/website/observability/overview.md b/website/observability/overview.md index e46c410bc..654cfa07e 100644 --- a/website/observability/overview.md +++ b/website/observability/overview.md @@ -28,5 +28,6 @@ Experience VoltOps in action with [**Live Demo**](https://console.voltagent.dev/ 1. [**Setup**](setup) - Connect your VoltAgent app to VoltOps in a few minutes. 2. [**Mental Model**](mental-model) - Learn how traces, spans, and context map to the UI. -3. [**MCP**](mcp) - Let AI coding agents inspect traces and logs through a read-only MCP server. -4. [**Tracing Overview**](tracing/overview) - Continue with full tracing features. +3. [**Public Trace API**](public-trace-api) - Load persisted traces in workflow testers and internal tools. +4. [**MCP**](mcp) - Let AI coding agents inspect traces and logs through a read-only MCP server. +5. [**Tracing Overview**](tracing/overview) - Continue with full tracing features. diff --git a/website/observability/public-trace-api.md b/website/observability/public-trace-api.md new file mode 100644 index 000000000..c8c904922 --- /dev/null +++ b/website/observability/public-trace-api.md @@ -0,0 +1,238 @@ +--- +title: Public Trace API +description: Query VoltOps traces with project API keys for workflow testers, internal dashboards, and debugging tools. +--- + +# Public Trace API + +VoltOps exposes a project-key authenticated trace search endpoint for applications that need to load previous runs outside the VoltOps dashboard. + +This API is available on the Pro plan and above. + +Use this API when you are building: + +- workflow testers that preload earlier workflow runs +- internal debugging tools for product or support teams +- prompt iteration UIs that compare a new run against previous traces +- environment-aware replay tools, for example loading a production run into a staging tester + +The endpoint returns persisted VoltOps traces for one project with pagination, sorting, and the same core filters used by the tracing UI. + +## Availability + +Public trace search is available on VoltOps Pro and higher plans. Projects on plans without this feature receive an authorization or plan-limit response from the VoltOps API. + +## Endpoint + +```http +GET https://api.voltagent.dev/api/public/otel/v1/traces +``` + +Authenticate with project keys: + +```http +x-public-key: pk_xxxx +x-secret-key: sk_live_xxxx +``` + +The API keys determine the project scope. If a client sends a `projectId` query parameter, VoltOps ignores it and uses the project attached to the keys. + +:::caution +The secret key is sensitive. Call this endpoint from your backend, a server action, or another trusted runtime. Do not expose `x-secret-key` in browser code. +::: + +## Use from `@voltagent/core` + +```ts +import { VoltOpsClient } from "@voltagent/core"; + +const voltops = new VoltOpsClient({ + publicKey: process.env.VOLTAGENT_PUBLIC_KEY!, + secretKey: process.env.VOLTAGENT_SECRET_KEY!, +}); + +const traces = await voltops.observability.traces.list({ + search: "checkout", + environments: ["dev", "prod"], + status: ["success", "error"], + limit: 20, + offset: 0, + sortBy: "start_time", + sortOrder: "desc", +}); + +console.log(traces.data); +``` + +The same client works for packages and applications built on top of `@voltagent/core`, including a workflow tester service that already has access to your VoltOps project keys. + +## Use from `@voltagent/sdk` + +If your integration uses the REST SDK: + +```ts +import { VoltAgentCoreAPI } from "@voltagent/sdk"; + +const api = new VoltAgentCoreAPI({ + publicKey: process.env.VOLTAGENT_PUBLIC_KEY!, + secretKey: process.env.VOLTAGENT_SECRET_KEY!, +}); + +const traces = await api.observability.traces.list({ + conversationId: "conv_123", + limit: 10, +}); +``` + +`@voltagent/sdk` also exports `VoltOpsClient`, which extends the core client and supports the same `voltops.observability.traces.list()` call. + +## Use with `fetch` + +```ts +const params = new URLSearchParams({ + search: "invoice workflow", + environments: "production", + limit: "25", + offset: "0", + sortBy: "start_time", + sortOrder: "desc", +}); + +const response = await fetch(`https://api.voltagent.dev/api/public/otel/v1/traces?${params}`, { + headers: { + "x-public-key": process.env.VOLTAGENT_PUBLIC_KEY!, + "x-secret-key": process.env.VOLTAGENT_SECRET_KEY!, + }, +}); + +if (!response.ok) { + throw new Error(`Trace search failed: ${response.status}`); +} + +const traces = await response.json(); +``` + +## Query Options + +All query parameters are optional. Values that accept multiple entries can be sent as comma-separated strings, or as arrays when using the SDK helpers. + +| Parameter | Type | Description | +| -------------------- | ------------------ | ----------------------------------------------------- | +| `limit` | number | Page size. Defaults to the API default. | +| `offset` | number | Number of traces to skip. | +| `sortBy` | string | Sort field, commonly `start_time`. | +| `sortOrder` | `asc` or `desc` | Sort direction. | +| `search` | string | Text search across trace fields. | +| `traceId` | string | Match a specific trace ID. | +| `agentId` | string or string[] | Filter by agent or workflow entity ID. | +| `entityType` | string or string[] | Filter by entity type, such as `agent` or `workflow`. | +| `conversationId` | string | Filter traces from one conversation/session. | +| `userId` | string | Filter traces for one user. | +| `model` | string or string[] | Filter by model name. | +| `status` | string or string[] | Filter by trace status. | +| `level` | string or string[] | Filter by trace level. | +| `tags` | string or string[] | Filter by trace tags. | +| `environments` | string or string[] | Filter by `environment` resource attribute. | +| `startDate` | string or Date | Include traces starting after this time. | +| `endDate` | string or Date | Include traces starting before this time. | +| `minTokens` | number | Minimum token count. | +| `maxTokens` | number | Maximum token count. | +| `minCost` | number | Minimum cost. | +| `maxCost` | number | Maximum cost. | +| `minDuration` | number | Minimum duration in milliseconds. | +| `maxDuration` | number | Maximum duration in milliseconds. | +| `promptId` | string | Filter by prompt ID. | +| `promptVersion` | string | Filter by prompt version. | +| `feedbackKey` | string or string[] | Filter by feedback key. | +| `feedbackScore` | number | Match an exact feedback score. | +| `minFeedbackScore` | number | Minimum feedback score. | +| `maxFeedbackScore` | number | Maximum feedback score. | +| `feedbackValue` | string | Filter by feedback value. | +| `feedbackSourceType` | string or string[] | Filter by feedback source type. | + +## Response Shape + +```ts +type TraceListResponse = { + data: Array<{ + trace_id: string; + project_id: string; + agent_id?: string; + entity_type?: string; + user_id?: string | null; + conversation_id?: string | null; + start_time: string; + end_time?: string | null; + status?: string | null; + input?: unknown; + output?: unknown; + usage?: unknown; + metadata?: Record | null; + tags?: string[] | null; + model?: string | null; + level?: string | null; + status_message?: string | null; + service_name?: string; + span_count?: number; + error_count?: number; + duration_ms?: number | null; + resource_attributes?: Record | null; + latest_feedback_score?: number | null; + latest_feedback_key?: string | null; + latest_feedback_comment?: string | null; + latest_feedback_at?: string | null; + }>; + total: number; + pageCount: number; +}; +``` + +Use `total`, `pageCount`, `limit`, and `offset` to build paginated trace pickers. + +## Workflow Tester Pattern + +A common workflow tester flow looks like this: + +1. Search previous traces by workflow ID, environment, user, or free text. +2. Let the user select a trace. +3. Use the selected trace's `input`, `metadata`, `conversation_id`, or tags to prefill the tester. +4. Run the workflow with modified prompts or config. +5. Compare the new trace against the selected previous trace in VoltOps. + +Example server-side loader: + +```ts +import { VoltOpsClient } from "@voltagent/core"; + +const voltops = new VoltOpsClient({ + publicKey: process.env.VOLTAGENT_PUBLIC_KEY!, + secretKey: process.env.VOLTAGENT_SECRET_KEY!, +}); + +export async function loadWorkflowRuns(workflowId: string, environment: string) { + return await voltops.observability.traces.list({ + entityType: "workflow", + agentId: workflowId, + environments: environment, + limit: 50, + sortBy: "start_time", + sortOrder: "desc", + }); +} +``` + +For cross-environment testing, use the keys for the source environment/project when loading old runs, then run the tester with the target environment's configuration. + +## Local Hono/Elysia Routes vs VoltOps API + +The local server routes such as `/observability/traces` read from the local in-memory or configured observability storage attached to the running app. They are useful for local development and live debugging. + +The VoltOps public trace API reads persisted traces from VoltOps. Use it when you need older runs, production runs, or traces from a different deployed environment. + +## Security Notes + +- Treat `VOLTAGENT_SECRET_KEY` as backend-only. +- Gate access in your own application to users who should be allowed to inspect persisted traces. VoltOps enforces the project plan on the API side. +- Scope each workflow tester deployment to the minimum project keys it needs. +- Prefer separate VoltOps projects for dev, staging, and production if users need clear environment boundaries. +- Log trace IDs, not full trace payloads, when debugging your tester. diff --git a/website/sidebarsObservability.ts b/website/sidebarsObservability.ts index 60a9b7fd4..7d718f9f5 100644 --- a/website/sidebarsObservability.ts +++ b/website/sidebarsObservability.ts @@ -16,6 +16,7 @@ const sidebars: SidebarsConfig = { }, "setup", "mental-model", + "public-trace-api", "mcp", ], },