Skip to content

Commit f33fc83

Browse files
fix(app): count legacy STATUS_CODE_ERROR spans as errors (#3181)
## Summary The service dashboard and the LLM expressions each spelled out their own "is this an error" predicate as `lower(<status>) = 'error'`. Both are fed `source.statusCodeExpression` for trace sources (`serviceDashboard.ts:164`, `llm/lib/expressions.ts:443`), so a collector that writes the long OTel form, `STATUS_CODE_ERROR`, fell outside the predicate and those spans went uncounted. Error rate and error counts on the Services and LLM dashboards undercounted as a result. This pulls the definition into one exported helper, `errorStatusPredicateSql` in `source.ts`, and matches both the short `Error` form and the long `STATUS_CODE_ERROR` one. The helper is span-status only. Log severity text is a different vocabulary: the long status value can never appear in it, and the values severity text does use for errors (`fatal`, `crit` and friends) are a different set again. So the log call site (`expressions.ts:487`) keeps a predicate of its own, unchanged from before this branch, rather than borrowing the span-status one. A single definition for span status also means the next place that needs it does not add another spelling. ## Test plan - Updated the existing expectations in `expressions.test.ts` and `serviceDashboard.test.ts` to the widened predicate; both suites pass. Both of those assertions cover trace sources, which is the path that changes. - Full app suite green (246 files, 4035 tests), plus `tsc --noEmit` and `eslint` with no new warnings. No UI or layout change: this only widens which spans an existing query counts as errors. [ui-check: allow] ## Notes - Adds a `patch` changeset. - Two other places still carry their own error-status spelling: the service map (`useServiceMap.tsx:178`, `ServiceMapTooltip.tsx:81`) and the client-side check in `DBTraceWaterfallChart.tsx`. Both are outside this PR's files, and the waterfall one needs a JS-side predicate rather than SQL, so they are tracked in #3203 instead of widening this change.
1 parent 2767b5c commit f33fc83

6 files changed

Lines changed: 38 additions & 6 deletions

File tree

‎.changeset/error-span-predicate.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
Count spans recorded with the legacy `STATUS_CODE_ERROR` status as errors on the Services and LLM dashboards. The errored-span definition now lives in one place and matches both that value and the short `Error` form, so those dashboards no longer undercount errors from collectors that emit the long form.

‎packages/app/src/__tests__/serviceDashboard.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ describe('Service Dashboard', () => {
460460

461461
expect(result.current.isLoading).toBe(false);
462462
expect(result.current.expressions?.isError).toBe(
463-
"lower(StatusCode) = 'error'",
463+
"lower(StatusCode) IN ('error', 'status_code_error')",
464464
);
465465
expect(result.current.expressions?.isSpanKindServer).toContain(
466466
'SpanKind IN',

‎packages/app/src/llm/__tests__/expressions.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ describe('getLLMExpressions', () => {
3232
expect(expressions.isLLMSpan).toContain(
3333
"mapContains(SpanAttributes, 'gen_ai.operation.name')",
3434
);
35-
expect(expressions.isError).toBe("lower(StatusCode) = 'error'");
35+
expect(expressions.isError).toBe(
36+
"lower(StatusCode) IN ('error', 'status_code_error')",
37+
);
3638
expect(expressions.durationInMillis).toBe('Duration/1e6');
3739
// Provided cost takes precedence in the cost expression.
3840
expect(expressions.costUsd.startsWith('if((greatest(')).toBe(true);
@@ -274,7 +276,9 @@ describe('getLLMExpressions', () => {
274276
[],
275277
);
276278
expect(expressions.model).toContain("Attrs['gen_ai.response.model']");
277-
expect(expressions.isError).toBe("lower(Status) = 'error'");
279+
expect(expressions.isError).toBe(
280+
"lower(Status) IN ('error', 'status_code_error')",
281+
);
278282
expect(expressions.durationInMillis).toBe('DurationNs/1e6');
279283
});
280284
});

‎packages/app/src/llm/lib/expressions.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { TLogSource, TTraceSource } from '@hyperdx/common-utils/dist/types';
22

3+
import { errorStatusPredicateSql } from '@/source';
4+
35
import { generateCostSqlExpression } from './cost';
46
import { buildAnyKeyExistsSql, buildLLMSpanSqlPredicate } from './detect';
57

@@ -452,7 +454,7 @@ export function getLLMExpressions(source: TTraceSource, jsonColumns: string[]) {
452454
...attributeExpressions,
453455
...fieldExpressions,
454456
...auxExpressions,
455-
isError: `lower(${fieldExpressions.severityText}) = 'error'`,
457+
isError: errorStatusPredicateSql(fieldExpressions.severityText),
456458
hasTokens: `${attributeExpressions.totalTokens} > 0`,
457459
};
458460
}
@@ -481,7 +483,13 @@ export function getLLMLogExpressions(
481483
...attributeExpressions,
482484
service: source.serviceNameExpression || 'ServiceName',
483485
severityText,
484-
/** Error log events (mirrors the serviceDashboard convention). */
486+
/**
487+
* Error log events. Severity text has its own vocabulary, so this keeps a
488+
* predicate of its own rather than reusing the span-status one: the extra
489+
* status value that predicate matches cannot appear in a severity-text
490+
* column, and the values severity text does use for errors are a
491+
* different set.
492+
*/
485493
isError: `lower(${severityText}) = 'error'`,
486494
/**
487495
* Log events that belong to LLM activity: explicit LLM markers or any

‎packages/app/src/serviceDashboard.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { escapeSqlString } from '@hyperdx/common-utils/dist/core/utils';
55
import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types';
66

77
import { useColumns, useJsonColumns } from './hooks/useMetadata';
8+
import { errorStatusPredicateSql } from './source';
89

910
const COALESCE_FIELDS_LIMIT = 100;
1011

@@ -225,7 +226,7 @@ export function getExpressions(
225226

226227
const filterExpressions = {
227228
isEndpointNonEmpty: `NOT empty(${auxExpressions.endpoint})`,
228-
isError: `lower(${fieldExpressions.severityText}) = 'error'`,
229+
isError: errorStatusPredicateSql(fieldExpressions.severityText),
229230
isSpanKindServer: `${fieldExpressions.spanKind} IN ('Server', 'SPAN_KIND_SERVER')`,
230231
isDbSpan: `${fieldExpressions.dbStatement} <> ''`,
231232
};

‎packages/app/src/source.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,20 @@ export function getDurationSecondsExpression(source: TTraceSource) {
529529
return `(${source.durationExpression})/1e${source.durationPrecision ?? 9}`;
530530
}
531531

532+
/**
533+
* The single definition of an errored span, as a SQL predicate over a span
534+
* status-code expression. Matches both the short OTel status ('Error'/'ERROR')
535+
* and the legacy collector form ('STATUS_CODE_ERROR'), which some collectors
536+
* write instead.
537+
*
538+
* Span status only. Log severity text is a different vocabulary (its error
539+
* values are 'fatal', 'crit' and friends, never 'status_code_error'), so log
540+
* callers keep their own predicate rather than reusing this one.
541+
*/
542+
export function errorStatusPredicateSql(statusCodeExpression: string): string {
543+
return `lower(${statusCodeExpression}) IN ('error', 'status_code_error')`;
544+
}
545+
532546
// Aggregate functions whose output preserves the unit of the input value.
533547
// count and count_distinct produce dimensionless counts and should not
534548
// inherit the duration format.

0 commit comments

Comments
 (0)