diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index bb9ca4c462..d1e002abb5 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -527,6 +527,7 @@ export function useTree({ /** An actual tree structure with custom data */ export type Tree = { id: string; + runId?: string; children?: Tree[]; data: TData; }; @@ -535,6 +536,7 @@ export type Tree = { export type FlatTreeItem = { id: string; parentId?: string | undefined; + runId?: string; children: string[]; hasChildren: boolean; /** The indentation level, the root is 0 */ @@ -552,6 +554,7 @@ export function flattenTree(tree: Tree): FlatTree { flatTree.push({ id: node.id, parentId, + runId: node.runId, children, hasChildren: children.length > 0, level, @@ -571,6 +574,7 @@ export function flattenTree(tree: Tree): FlatTree { type FlatTreeWithoutChildren = { id: string; parentId: string | undefined; + runId?: string; data: TData; }; @@ -580,7 +584,7 @@ export function createTreeFromFlatItems( ): Tree | undefined { // Index items by id const indexedItems: { [id: string]: Tree } = withoutChildren.reduce((acc, item) => { - acc[item.id] = { id: item.id, data: item.data, children: [] }; + acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] }; return acc; }, {} as { [id: string]: Tree }); diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 437d6b6458..5e8dab2d0b 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -209,6 +209,10 @@ export class RunPresenter { //we need the start offset for each item, and the total duration of the entire tree const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0; let totalDuration = tree?.data.duration ?? 0; + + // Build the linkedRunIdBySpanId map during the same walk + const linkedRunIdBySpanId: Record = {}; + const events = tree ? flattenTree(tree).map((n) => { const offset = millisecondsToNanoseconds( @@ -218,6 +222,12 @@ export class RunPresenter { if (!n.data.isDebug) { totalDuration = Math.max(totalDuration, offset + n.data.duration); } + + // For cached spans, store the mapping from spanId to the linked run's ID + if (n.data.style?.icon === "task-cached" && n.runId) { + linkedRunIdBySpanId[n.id] = n.runId; + } + return { ...n, data: { @@ -260,6 +270,7 @@ export class RunPresenter { ? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime()) : undefined, overridesBySpanId: traceSummary.overridesBySpanId, + linkedRunIdBySpanId, }, maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting, }; diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 4c0e3405cf..e1a60cfe6f 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -35,11 +35,13 @@ export class SpanPresenter extends BasePresenter { projectSlug, spanId, runFriendlyId, + linkedRunId, }: { userId: string; projectSlug: string; spanId: string; runFriendlyId: string; + linkedRunId?: string; }) { const project = await this._replica.project.findFirst({ where: { @@ -88,6 +90,7 @@ export class SpanPresenter extends BasePresenter { traceId, eventRepository, spanId, + linkedRunId, createdAt: parentRun.createdAt, completedAt: parentRun.completedAt, environmentId: parentRun.runtimeEnvironmentId, @@ -126,6 +129,7 @@ export class SpanPresenter extends BasePresenter { traceId, eventRepository, spanId, + linkedRunId, createdAt, completedAt, }: { @@ -134,19 +138,12 @@ export class SpanPresenter extends BasePresenter { traceId: string; eventRepository: IEventRepository; spanId: string; + linkedRunId?: string; createdAt: Date; completedAt: Date | null; }) { - const originalRunId = await eventRepository.getSpanOriginalRunId( - eventStore, - environmentId, - spanId, - traceId, - createdAt, - completedAt ?? undefined - ); - - const run = await this.findRun({ originalRunId, spanId, environmentId }); + // Use linkedRunId if provided (for cached spans), otherwise look up by spanId + const run = await this.findRun({ originalRunId: linkedRunId, spanId, environmentId }); if (!run) { return; @@ -272,7 +269,7 @@ export class SpanPresenter extends BasePresenter { workerQueue: run.workerQueue, traceId: run.traceId, spanId: run.spanId, - isCached: !!originalRunId, + isCached: !!linkedRunId, machinePreset: machine?.name, taskEventStore: run.taskEventStore, externalTraceId, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index f73d380b9c..899306eb81 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -476,6 +476,12 @@ function TraceView({ const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined; + // Get the linked run ID for cached spans (map built during RunPresenter walk) + const { linkedRunIdBySpanId } = trace; + const selectedSpanLinkedRunId = selectedSpanId + ? linkedRunIdBySpanId?.[selectedSpanId] + : undefined; + return (
replaceSearchParam("span")} + linkedRunId={selectedSpanLinkedRunId} /> )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index b16cc97f7f..2365e74904 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -93,6 +93,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, envParam, runParam, spanParam } = v3SpanParamsSchema.parse(params); + const url = new URL(request.url); + const linkedRunId = url.searchParams.get("linkedRunId") ?? undefined; + const presenter = new SpanPresenter(); try { @@ -101,6 +104,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { spanId: spanParam, runFriendlyId: runParam, userId, + linkedRunId, }); return typedjson(result); @@ -130,11 +134,13 @@ export function SpanView({ spanId, spanOverrides, closePanel, + linkedRunId, }: { runParam: string; spanId: string | undefined; spanOverrides?: SpanOverride; closePanel?: () => void; + linkedRunId?: string; }) { const organization = useOrganization(); const project = useProject(); @@ -143,10 +149,11 @@ export function SpanView({ useEffect(() => { if (spanId === undefined) return; - fetcher.load( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runParam}/spans/${spanId}` - ); - }, [organization.slug, project.slug, environment.slug, runParam, spanId]); + const url = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${ + environment.slug + }/runs/${runParam}/spans/${spanId}${linkedRunId ? `?linkedRunId=${linkedRunId}` : ""}`; + fetcher.load(url); + }, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId]); if (spanId === undefined) { return null; @@ -305,7 +312,12 @@ function RunBody({ useEffect(() => { if (resetFetcher.data && resetFetcher.state === "idle") { // Check if the response indicates success - if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) { + if ( + resetFetcher.data && + typeof resetFetcher.data === "object" && + "success" in resetFetcher.data && + resetFetcher.data.success === true + ) { toast.custom( (t) => (
{run.idempotencyKey ? ( - + ) : (
)} @@ -951,7 +967,9 @@ function RunBody({ {run.logsDeletedAt === null ? ( <> View logs @@ -968,8 +986,6 @@ function RunBody({ ) : null} - -
diff --git a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts index ebf20637d5..dcadfe69ce 100644 --- a/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts @@ -59,7 +59,6 @@ import type { TraceEventOptions, TraceSummary, } from "./eventRepository.types"; -import { originalRunIdCache } from "./originalRunIdCache.server"; export type ClickhouseEventRepositoryConfig = { clickhouse: ClickHouse; @@ -705,13 +704,6 @@ export class ClickhouseEventRepository implements IEventRepository { expires_at: convertDateToClickhouseDateTime(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)), }; - const originalRunId = - options.attributes.properties?.[SemanticInternalAttributes.ORIGINAL_RUN_ID]; - - if (typeof originalRunId === "string") { - await originalRunIdCache.set(traceId, spanId, originalRunId); - } - const events = [event]; if (failedWithError) { @@ -1197,17 +1189,6 @@ export class ClickhouseEventRepository implements IEventRepository { return span; } - async getSpanOriginalRunId( - storeTable: TaskEventStoreTable, - environmentId: string, - spanId: string, - traceId: string, - startCreatedAt: Date, - endCreatedAt?: Date - ): Promise { - return await originalRunIdCache.lookup(traceId, spanId); - } - #mergeRecordsIntoSpanDetail( spanId: string, records: TaskEventDetailsV1Result[], diff --git a/apps/webapp/app/v3/eventRepository/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository/eventRepository.server.ts index 96df1fb353..de2a19e395 100644 --- a/apps/webapp/app/v3/eventRepository/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository/eventRepository.server.ts @@ -64,7 +64,6 @@ import type { TraceEventOptions, TraceSummary, } from "./eventRepository.types"; -import { originalRunIdCache } from "./originalRunIdCache.server"; const MAX_FLUSH_DEPTH = 5; @@ -817,40 +816,6 @@ export class EventRepository implements IEventRepository { }); } - async getSpanOriginalRunId( - storeTable: TaskEventStoreTable, - environmentId: string, - spanId: string, - traceId: string, - startCreatedAt: Date, - endCreatedAt?: Date - ): Promise { - return await startActiveSpan("getSpanOriginalRunId", async (s) => { - return await originalRunIdCache.swr(traceId, spanId, async () => { - const spanEvent = await this.#getSpanEvent({ - storeTable, - spanId, - environmentId, - startCreatedAt, - endCreatedAt, - options: { includeDebugLogs: false }, - }); - - if (!spanEvent) { - return; - } - // This is used when the span is a cached run (because of idempotency key) - // so this span isn't the actual run span, but points to the original run - const originalRun = rehydrateAttribute( - spanEvent.properties, - SemanticInternalAttributes.ORIGINAL_RUN_ID - ); - - return originalRun; - }); - }); - } - async #createSpanFromEvent( storeTable: TaskEventStoreTable, event: PreparedEvent, diff --git a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts index dcbdb07a3d..7c49f52c56 100644 --- a/apps/webapp/app/v3/eventRepository/eventRepository.types.ts +++ b/apps/webapp/app/v3/eventRepository/eventRepository.types.ts @@ -393,15 +393,6 @@ export interface IEventRepository { options?: { includeDebugLogs?: boolean } ): Promise; - getSpanOriginalRunId( - storeTable: TaskEventStoreTable, - environmentId: string, - spanId: string, - traceId: string, - startCreatedAt: Date, - endCreatedAt?: Date - ): Promise; - // Event recording methods recordEvent( message: string, diff --git a/apps/webapp/app/v3/eventRepository/originalRunIdCache.server.ts b/apps/webapp/app/v3/eventRepository/originalRunIdCache.server.ts deleted file mode 100644 index caad8885ef..0000000000 --- a/apps/webapp/app/v3/eventRepository/originalRunIdCache.server.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - createCache, - DefaultStatefulContext, - Namespace, - RedisCacheStore, - type UnkeyCache, -} from "@internal/cache"; -import type { RedisOptions } from "@internal/redis"; -import { env } from "~/env.server"; -import { singleton } from "~/utils/singleton"; - -export type OriginalRunIdCacheOptions = { - redisOptions: RedisOptions; -}; - -const ORIGINAL_RUN_ID_FRESH_TTL = 60000 * 60 * 24 * 30; // 30 days -const ORIGINAL_RUN_ID_STALE_TTL = 60000 * 60 * 24 * 31; // 31 days - -export class OriginalRunIdCache { - private readonly cache: UnkeyCache<{ - originalRunId: string; - }>; - - constructor(options: OriginalRunIdCacheOptions) { - // Initialize cache - const ctx = new DefaultStatefulContext(); - const redisCacheStore = new RedisCacheStore({ - name: "original-run-id-cache", - connection: { - ...options.redisOptions, - keyPrefix: "original-run-id-cache:", - }, - useModernCacheKeyBuilder: true, - }); - - this.cache = createCache({ - originalRunId: new Namespace(ctx, { - stores: [redisCacheStore], - fresh: ORIGINAL_RUN_ID_FRESH_TTL, - stale: ORIGINAL_RUN_ID_STALE_TTL, - }), - }); - } - - public async lookup(traceId: string, spanId: string) { - const result = await this.cache.originalRunId.get(`${traceId}:${spanId}`); - - return result.val; - } - - public async set(traceId: string, spanId: string, originalRunId: string) { - await this.cache.originalRunId.set(`${traceId}:${spanId}`, originalRunId); - } - - public async swr(traceId: string, spanId: string, callback: () => Promise) { - const result = await this.cache.originalRunId.swr(`${traceId}:${spanId}`, callback); - - return result.val; - } -} - -export const originalRunIdCache = singleton( - "originalRunIdCache", - () => - new OriginalRunIdCache({ - redisOptions: { - port: env.REDIS_PORT ?? undefined, - host: env.REDIS_HOST ?? undefined, - username: env.REDIS_USERNAME ?? undefined, - password: env.REDIS_PASSWORD ?? undefined, - enableAutoPipelining: true, - ...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), - }, - }) -);