Skip to content

Commit db3a11b

Browse files
authored
Fix incorrect latency after tool timeout (#15)
* Add timeout regression test * End timed out tool spans
1 parent e5916b2 commit db3a11b

3 files changed

Lines changed: 96 additions & 2 deletions

File tree

src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,18 @@ const eventHook = (event: OpencodeEvent, shutdown?: () => Promise<void>) =>
166166
}
167167

168168
if (event.type === "message.part.updated") {
169-
langfuse.rememberAssistantPart(event.properties.part);
170-
langfuse.traceReasoningPart(event.properties.part);
169+
const part = event.properties.part;
170+
171+
langfuse.rememberAssistantPart(part);
172+
langfuse.traceReasoningPart(part);
173+
174+
if (part.type === "tool" && part.state.status === "error") {
175+
langfuse.traceToolError({
176+
callID: part.callID,
177+
error: part.state.error,
178+
completed: part.state.time.end,
179+
});
180+
}
171181
}
172182

173183
if (event.type === "session.next.step.started") {

src/langfuse.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class LangfuseClient {
4040
this.traceState.generationParentSpans.clear();
4141
this.traceState.turnObservationsByMessageId.clear();
4242
this.traceState.latestTurnObservationsBySession.clear();
43+
this.traceState.finalizedToolCallIds.clear();
4344
}
4445

4546
endActiveToolObservations(sessionID?: string, error?: SessionErrorInfo) {
@@ -61,6 +62,7 @@ export class LangfuseClient {
6162

6263
observation.span.end();
6364
this.traceState.activeToolObservations.delete(callID);
65+
this.traceState.finalizedToolCallIds.add(callID);
6466
}
6567
}
6668

@@ -707,6 +709,7 @@ export class LangfuseClient {
707709
args: unknown;
708710
}) {
709711
this.traceState.activeToolObservations.get(input.callID)?.span.end();
712+
this.traceState.finalizedToolCallIds.delete(input.callID);
710713
this.ensureGenerationParent(input.sessionID);
711714

712715
this.withObservationParent(input.sessionID, () => {
@@ -738,6 +741,10 @@ export class LangfuseClient {
738741
title: string;
739742
output: string;
740743
}) {
744+
if (this.traceState.finalizedToolCallIds.has(input.callID)) {
745+
return;
746+
}
747+
741748
if (!this.traceState.activeToolObservations.has(input.callID)) {
742749
this.traceToolStart({
743750
sessionID: input.sessionID,
@@ -767,6 +774,32 @@ export class LangfuseClient {
767774

768775
span.end();
769776
this.traceState.activeToolObservations.delete(input.callID);
777+
this.traceState.finalizedToolCallIds.add(input.callID);
778+
}
779+
780+
traceToolError(input: { callID: string; error: string; completed: number }) {
781+
if (this.traceState.finalizedToolCallIds.has(input.callID)) {
782+
return;
783+
}
784+
785+
const span = this.traceState.activeToolObservations.get(input.callID)?.span;
786+
787+
if (!span) {
788+
return;
789+
}
790+
791+
span.setAttribute(
792+
"langfuse.observation.output",
793+
JSON.stringify({ error: input.error }),
794+
);
795+
span.setStatus({
796+
code: SpanStatusCode.ERROR,
797+
message: input.error,
798+
});
799+
span.recordException({ message: input.error });
800+
span.end(new Date(input.completed));
801+
this.traceState.activeToolObservations.delete(input.callID);
802+
this.traceState.finalizedToolCallIds.add(input.callID);
770803
}
771804

772805
private ensureGenerationParent(sessionID: string) {
@@ -873,6 +906,7 @@ export type LangfuseTraceState = {
873906
turnObservationsByMessageId: Map<string, TurnObservation>;
874907
latestTurnObservationsBySession: Map<string, TurnObservation>;
875908
activeToolObservations: Map<string, ToolObservation>;
909+
finalizedToolCallIds: Set<string>;
876910
activeGenerationSteps: Map<string, ActiveGenerationStep>;
877911
generationParentSpans: Map<string, ApiSpan>;
878912
};
@@ -1029,6 +1063,7 @@ export const createLangfuseClient = (input: {
10291063
turnObservationsByMessageId: new Map<string, TurnObservation>(),
10301064
latestTurnObservationsBySession: new Map<string, TurnObservation>(),
10311065
activeToolObservations: new Map<string, ToolObservation>(),
1066+
finalizedToolCallIds: new Set<string>(),
10321067
activeGenerationSteps: new Map<string, ActiveGenerationStep>(),
10331068
generationParentSpans: new Map<string, ApiSpan>(),
10341069
};

test/integration/plugin.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface OtlpSpan {
2424
traceId: string;
2525
spanId: string;
2626
parentSpanId?: string;
27+
startTimeUnixNano: string;
28+
endTimeUnixNano: string;
2729
attributes: Array<{ key: string; value: OtlpValue }>;
2830
status?: { code?: number; message?: string };
2931
events?: Array<{ name: string }>;
@@ -586,6 +588,40 @@ describe.sequential("built plugin", () => {
586588
},
587589
{ title: "README.md", output: "# Project", metadata: {} },
588590
);
591+
const failedToolStarted = Date.now();
592+
const failedToolEnded = failedToolStarted + 5_000;
593+
await hooks["tool.execute.before"]?.(
594+
{ sessionID, callID: "timed-out-webfetch", tool: "webfetch" },
595+
{ args: { url: "https://example.com", timeout: 5 } },
596+
);
597+
await emitEvent({
598+
type: "message.part.updated",
599+
properties: {
600+
part: {
601+
id: "timed-out-webfetch-part",
602+
sessionID,
603+
messageID: assistantMessageID,
604+
type: "tool",
605+
callID: "timed-out-webfetch",
606+
tool: "webfetch",
607+
state: {
608+
status: "error",
609+
input: { url: "https://example.com", timeout: 5 },
610+
error: "Tool execution timed out after 5 seconds",
611+
time: { start: failedToolStarted, end: failedToolEnded },
612+
},
613+
},
614+
},
615+
});
616+
await hooks["tool.execute.after"]?.(
617+
{
618+
sessionID,
619+
callID: "timed-out-webfetch",
620+
tool: "webfetch",
621+
args: { url: "https://example.com", timeout: 5 },
622+
},
623+
{ title: "Web fetch", output: "late output", metadata: {} },
624+
);
589625
await emitEvent({
590626
id: "nested-observations-retry",
591627
type: "session.next.retried",
@@ -624,6 +660,7 @@ describe.sequential("built plugin", () => {
624660
"opencode.generation.reasoning",
625661
"opencode.generation.reasoning",
626662
"read",
663+
"webfetch",
627664
"opencode.generation.retry",
628665
"opencode.generation.compaction",
629666
].sort(),
@@ -642,6 +679,18 @@ describe.sequential("built plugin", () => {
642679
expect(tool.traceId).toBe(generation.traceId);
643680
expect(tool.parentSpanId).toBe(generation.spanId);
644681

682+
const failedTool = getSpan(spans, "webfetch");
683+
expect(failedTool.endTimeUnixNano).toBe(
684+
(BigInt(failedToolEnded) * 1_000_000n).toString(),
685+
);
686+
expect(failedTool.status).toEqual({
687+
code: 2,
688+
message: "Tool execution timed out after 5 seconds",
689+
});
690+
expect(getJsonAttribute(failedTool, "langfuse.observation.output")).toEqual(
691+
{ error: "Tool execution timed out after 5 seconds" },
692+
);
693+
645694
const retry = getSpan(spans, "opencode.generation.retry");
646695
expect(getJsonAttribute(retry, "langfuse.observation.metadata")).toEqual({
647696
attempt: 2,

0 commit comments

Comments
 (0)