Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(mcp): add get_span_details tool#3255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
f9c3573c2fdff7922c34f9a5e7c06d9e386File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@trigger.dev/core": patch | ||
| "trigger.dev": patch | ||
| --- | ||
| Add `get_span_details` MCP tool for inspecting individual spans within a run trace. | ||
| - New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed) | ||
| - Span IDs now shown in `get_run_details` trace output for easy discovery | ||
| - New API endpoint `GET /api/v1/runs/:runId/spans/:spanId` | ||
| - New `retrieveSpan()` method on the API client |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
| Add API endpoint `GET /api/v1/runs/:runId/spans/:spanId` that returns detailed span information including properties, events, AI enrichment (model, tokens, cost), and triggered child runs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { BatchId } from "@trigger.dev/core/v3/isomorphic"; | ||
| import { z } from "zod"; | ||
| import { $replica } from "~/db.server"; | ||
| import { extractAISpanData } from "~/components/runs/v3/ai"; | ||
| import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
| import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; | ||
| import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; | ||
| const ParamsSchema = z.object({ | ||
| runId: z.string(), | ||
| spanId: z.string(), | ||
| }); | ||
| export const loader = createLoaderApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| findResource: (params, auth) => { | ||
| return $replica.taskRun.findFirst({ | ||
| where: { | ||
| friendlyId: params.runId, | ||
| runtimeEnvironmentId: auth.environment.id, | ||
| }, | ||
| }); | ||
| }, | ||
| shouldRetryNotFound: true, | ||
| authorization: { | ||
| action: "read", | ||
| resource: (run) => ({ | ||
| runs: run.friendlyId, | ||
| tags: run.runTags, | ||
| batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined, | ||
| tasks: run.taskIdentifier, | ||
| }), | ||
| superScopes: ["read:runs", "read:all", "admin"], | ||
| }, | ||
| }, | ||
| async ({ params, resource: run, authentication }) => { | ||
| const eventRepository = resolveEventRepositoryForStore(run.taskEventStore); | ||
| const eventStore = getTaskEventStoreTableForRun(run); | ||
| const span = await eventRepository.getSpan( | ||
| eventStore, | ||
| authentication.environment.id, | ||
| params.spanId, | ||
| run.traceId, | ||
| run.createdAt, | ||
| run.completedAt ?? undefined | ||
| ); | ||
| if (!span) { | ||
| return json({ error: "Span not found" }, { status: 404 }); | ||
| } | ||
| // Duration is nanoseconds from ClickHouse (Postgres store is deprecated) | ||
| const durationMs = span.duration / 1_000_000; | ||
| const aiData = | ||
| span.properties && typeof span.properties === "object" | ||
| ? extractAISpanData(span.properties as Record<string, unknown>, durationMs) | ||
| : undefined; | ||
| const triggeredRuns = await $replica.taskRun.findMany({ | ||
| take: 50, | ||
| select: { | ||
| friendlyId: true, | ||
| taskIdentifier: true, | ||
| status: true, | ||
| createdAt: true, | ||
| }, | ||
| where: { | ||
| runtimeEnvironmentId: authentication.environment.id, | ||
| parentSpanId: params.spanId, | ||
| }, | ||
| }); | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const properties = | ||
| span.properties && | ||
| typeof span.properties === "object" && | ||
| Object.keys(span.properties as Record<string, unknown>).length > 0 | ||
| ? (span.properties as Record<string, unknown>) | ||
| : undefined; | ||
| return json( | ||
| { | ||
| spanId: span.spanId, | ||
| parentId: span.parentId, | ||
| runId: run.friendlyId, | ||
| message: span.message, | ||
| isError: span.isError, | ||
| isPartial: span.isPartial, | ||
| isCancelled: span.isCancelled, | ||
| level: span.level, | ||
| startTime: span.startTime, | ||
| durationMs, | ||
| properties, | ||
| events: span.events?.length ? span.events : undefined, | ||
| entityType: span.entity.type ?? undefined, | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ai: aiData | ||
| ? { | ||
| model: aiData.model, | ||
| provider: aiData.provider, | ||
| operationName: aiData.operationName, | ||
| inputTokens: aiData.inputTokens, | ||
| outputTokens: aiData.outputTokens, | ||
| totalTokens: aiData.totalTokens, | ||
| cachedTokens: aiData.cachedTokens, | ||
| reasoningTokens: aiData.reasoningTokens, | ||
| inputCost: aiData.inputCost, | ||
| outputCost: aiData.outputCost, | ||
| totalCost: aiData.totalCost, | ||
| tokensPerSecond: aiData.tokensPerSecond, | ||
| msToFirstChunk: aiData.msToFirstChunk, | ||
| durationMs: aiData.durationMs, | ||
| finishReason: aiData.finishReason, | ||
| responseText: aiData.responseText, | ||
| } | ||
| : undefined, | ||
| triggeredRuns: | ||
| triggeredRuns.length > 0 | ||
| ? triggeredRuns.map((r) => ({ | ||
| runId: r.friendlyId, | ||
| taskIdentifier: r.taskIdentifier, | ||
| status: r.status, | ||
| createdAt: r.createdAt, | ||
| })) | ||
| : undefined, | ||
| }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3,6 +3,7 @@ import { | ||
| ListRunResponseItem, | ||
| RetrieveRunResponse, | ||
| RetrieveRunTraceResponseBody, | ||
| RetrieveSpanDetailResponseBody, | ||
| } from "@trigger.dev/core/v3/schemas"; | ||
| import type { CursorPageResponse } from "@trigger.dev/core/v3/zodfetch"; | ||
| @@ -235,10 +236,11 @@ function formatSpan( | ||
| // Format span header | ||
| const statusIndicator = getStatusIndicator(span.data); | ||
| const duration = formatDuration(span.data.duration); | ||
| // Trace durations are nanoseconds from ClickHouse | ||
| const duration = formatDuration(span.data.duration / 1_000_000); | ||
| const startTime = formatDateTime(span.data.startTime); | ||
| lines.push(`${indent}${prefix} ${span.data.message} ${statusIndicator}`); | ||
| lines.push(`${indent}${prefix} [${span.id}] ${span.data.message} ${statusIndicator}`); | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| lines.push(`${indent} Duration: ${duration}`); | ||
| lines.push(`${indent} Started: ${startTime}`); | ||
| @@ -459,3 +461,94 @@ export function formatQueryResults(rows: Record<string, unknown>[]): string { | ||
| return [header, separator, ...body].join("\n"); | ||
| } | ||
| export function formatSpanDetail(span: RetrieveSpanDetailResponseBody): string { | ||
| const lines: string[] = []; | ||
| const statusIndicator = span.isCancelled | ||
| ? "[CANCELLED]" | ||
| : span.isError | ||
| ? "[ERROR]" | ||
| : span.isPartial | ||
| ? "[IN PROGRESS]" | ||
| : "[COMPLETED]"; | ||
| lines.push(`## Span: ${span.message} ${statusIndicator}`); | ||
| lines.push(`Span ID: ${span.spanId}`); | ||
| if (span.parentId) lines.push(`Parent ID: ${span.parentId}`); | ||
| lines.push(`Run ID: ${span.runId}`); | ||
| lines.push(`Level: ${span.level}`); | ||
| lines.push(`Started: ${formatDateTime(span.startTime)}`); | ||
| lines.push(`Duration: ${formatDuration(span.durationMs)}`); | ||
| if (span.entityType) lines.push(`Entity Type: ${span.entityType}`); | ||
| if (span.ai) { | ||
| lines.push(""); | ||
| lines.push("### AI Details"); | ||
| lines.push(`Model: ${span.ai.model}`); | ||
| lines.push(`Provider: ${span.ai.provider}`); | ||
| lines.push(`Operation: ${span.ai.operationName}`); | ||
| lines.push( | ||
| `Tokens: ${span.ai.inputTokens} in / ${span.ai.outputTokens} out (${span.ai.totalTokens} total)` | ||
| ); | ||
| if (span.ai.cachedTokens) { | ||
| lines.push(`Cached tokens: ${span.ai.cachedTokens}`); | ||
| } | ||
| if (span.ai.reasoningTokens) { | ||
| lines.push(`Reasoning tokens: ${span.ai.reasoningTokens}`); | ||
| } | ||
| if (span.ai.totalCost !== undefined) { | ||
| lines.push(`Cost: $${span.ai.totalCost.toFixed(6)}`); | ||
| if (span.ai.inputCost !== undefined && span.ai.outputCost !== undefined) { | ||
| lines.push( | ||
| ` Input: $${span.ai.inputCost.toFixed(6)}, Output: $${span.ai.outputCost.toFixed(6)}` | ||
| ); | ||
| } | ||
| } | ||
| if (span.ai.tokensPerSecond !== undefined) { | ||
| lines.push(`Speed: ${span.ai.tokensPerSecond} tokens/sec`); | ||
| } | ||
| if (span.ai.msToFirstChunk !== undefined) { | ||
| lines.push(`Time to first chunk: ${span.ai.msToFirstChunk.toFixed(0)}ms`); | ||
| } | ||
| if (span.ai.finishReason) { | ||
| lines.push(`Finish reason: ${span.ai.finishReason}`); | ||
| } | ||
| if (span.ai.responseText) { | ||
| lines.push(""); | ||
| lines.push("### AI Response"); | ||
| lines.push(span.ai.responseText); | ||
| } | ||
| } | ||
| if (span.properties && Object.keys(span.properties).length > 0) { | ||
| lines.push(""); | ||
| lines.push("### Properties"); | ||
| lines.push(JSON.stringify(span.properties, null, 2)); | ||
| } | ||
| if (span.events && span.events.length > 0) { | ||
| lines.push(""); | ||
| lines.push(`### Events (${span.events.length})`); | ||
| const maxEvents = 10; | ||
| for (let i = 0; i < Math.min(span.events.length, maxEvents); i++) { | ||
| const event = span.events[i]; | ||
| if (typeof event === "object" && event !== null) { | ||
| lines.push(JSON.stringify(event, null, 2)); | ||
| } | ||
| } | ||
| if (span.events.length > maxEvents) { | ||
| lines.push(`... and ${span.events.length - maxEvents} more events`); | ||
| } | ||
| } | ||
| if (span.triggeredRuns && span.triggeredRuns.length > 0) { | ||
| lines.push(""); | ||
| lines.push("### Triggered Runs"); | ||
| for (const run of span.triggeredRuns) { | ||
| lines.push(`- ${run.runId} (${run.taskIdentifier}) - ${run.status.toLowerCase()}`); | ||
| } | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3,8 +3,8 @@ import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { toolsMetadata } from "../config.js"; | ||
| import { formatRun, formatRunList, formatRunShape, formatRunTrace } from "../formatters.js"; | ||
| import { CommonRunsInput, GetRunDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js"; | ||
| import { formatRun, formatRunList, formatRunShape, formatRunTrace, formatSpanDetail } from "../formatters.js"; | ||
| import { CommonRunsInput, GetRunDetailsInput, GetSpanDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js"; | ||
| import { respondWithError, toolHandler } from "../utils.js"; | ||
| // Cache formatted traces in temp files keyed by runId. | ||
| @@ -156,6 +156,51 @@ export const getRunDetailsTool = { | ||
| }), | ||
| }; | ||
| export const getSpanDetailsTool = { | ||
| name: toolsMetadata.get_span_details.name, | ||
| title: toolsMetadata.get_span_details.title, | ||
| description: toolsMetadata.get_span_details.description, | ||
| inputSchema: GetSpanDetailsInput.shape, | ||
| handler: toolHandler(GetSpanDetailsInput.shape, async (input, { ctx }) => { | ||
| ctx.logger?.log("calling get_span_details", { input }); | ||
| if (ctx.options.devOnly && input.environment !== "dev") { | ||
| return respondWithError( | ||
| `This MCP server is only available for the dev environment. You tried to access the ${input.environment} environment. Remove the --dev-only flag to access other environments.` | ||
| ); | ||
| } | ||
| const projectRef = await ctx.getProjectRef({ | ||
| projectRef: input.projectRef, | ||
| cwd: input.configPath, | ||
| }); | ||
| const apiClient = await ctx.getApiClient({ | ||
| projectRef, | ||
| environment: input.environment, | ||
| scopes: [`read:runs:${input.runId}`], | ||
| branch: input.branch, | ||
| }); | ||
| const spanDetail = await apiClient.retrieveSpan(input.runId, input.spanId); | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const formatted = formatSpanDetail(spanDetail); | ||
| const runUrl = await ctx.getDashboardUrl( | ||
| `/projects/v3/${projectRef}/runs/${input.runId}` | ||
| ); | ||
| const content = [formatted]; | ||
| if (runUrl) { | ||
| content.push(""); | ||
| content.push(`[View run in dashboard](${runUrl})`); | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: content.join("\n") }], | ||
| }; | ||
| }), | ||
| }; | ||
| export const waitForRunToCompleteTool = { | ||
| name: toolsMetadata.wait_for_run_to_complete.name, | ||
| title: toolsMetadata.wait_for_run_to_complete.title, | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.