From 89b67b77100db3661b5e3cea2235d74e4bacd955 Mon Sep 17 00:00:00 2001 From: Spencer Torres Date: Mon, 19 Oct 2026 16:04:26 -0700 Subject: [PATCH 1/3] fix: read current OTel HTTP and RPC semantic conventions in the trace view (HDX-5731) --- .changeset/otel-semconv-http.md | 5 + packages/app/src/LogSidePanelElements.tsx | 34 +- packages/app/src/__tests__/utils.test.ts | 22 + .../app/src/components/DBRowOverviewPanel.tsx | 33 +- .../src/components/DBTraceWaterfallChart.tsx | 93 +++- .../src/components/HttpMessageSections.tsx | 129 ++++++ .../NetworkPropertyPanel.stories.tsx | 174 ++++++++ .../src/components/NetworkPropertyPanel.tsx | 419 ++++++------------ .../src/components/RequestDetailsTable.tsx | 37 ++ packages/app/src/components/RequestLine.tsx | 127 ++++++ .../src/components/RpcCallPanel.stories.tsx | 97 ++++ packages/app/src/components/RpcCallPanel.tsx | 109 +++++ .../DBRowOverviewPanel.http.test.tsx | 139 ++++++ .../__tests__/DBTraceWaterfallChart.test.tsx | 210 ++++++++- .../__tests__/NetworkPropertyPanel.test.tsx | 234 ++++++++++ .../__tests__/RpcCallPanel.test.tsx | 124 ++++++ packages/app/src/utils.ts | 21 + .../src/utils/__tests__/curlGenerator.test.ts | 38 ++ .../src/utils/__tests__/httpHeaders.test.ts | 103 +++++ .../__tests__/httpSpanAttributes.test.ts | 158 +++++++ .../app/src/utils/__tests__/httpUrl.test.ts | 120 +++++ .../utils/__tests__/rpcSpanAttributes.test.ts | 120 +++++ .../__tests__/spanAttributeReaders.test.ts | 83 ++++ packages/app/src/utils/curlGenerator.ts | 30 +- packages/app/src/utils/httpHeaders.ts | 94 ++++ packages/app/src/utils/httpSpanAttributes.ts | 224 ++++++++++ packages/app/src/utils/httpUrl.ts | 124 ++++++ packages/app/src/utils/rpcSpanAttributes.ts | 203 +++++++++ .../app/src/utils/spanAttributeReaders.ts | 118 +++++ 29 files changed, 3032 insertions(+), 390 deletions(-) create mode 100644 .changeset/otel-semconv-http.md create mode 100644 packages/app/src/components/HttpMessageSections.tsx create mode 100644 packages/app/src/components/NetworkPropertyPanel.stories.tsx create mode 100644 packages/app/src/components/RequestDetailsTable.tsx create mode 100644 packages/app/src/components/RequestLine.tsx create mode 100644 packages/app/src/components/RpcCallPanel.stories.tsx create mode 100644 packages/app/src/components/RpcCallPanel.tsx create mode 100644 packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx create mode 100644 packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx create mode 100644 packages/app/src/components/__tests__/RpcCallPanel.test.tsx create mode 100644 packages/app/src/utils/__tests__/curlGenerator.test.ts create mode 100644 packages/app/src/utils/__tests__/httpHeaders.test.ts create mode 100644 packages/app/src/utils/__tests__/httpSpanAttributes.test.ts create mode 100644 packages/app/src/utils/__tests__/httpUrl.test.ts create mode 100644 packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts create mode 100644 packages/app/src/utils/__tests__/spanAttributeReaders.test.ts create mode 100644 packages/app/src/utils/httpHeaders.ts create mode 100644 packages/app/src/utils/httpSpanAttributes.ts create mode 100644 packages/app/src/utils/httpUrl.ts create mode 100644 packages/app/src/utils/rpcSpanAttributes.ts create mode 100644 packages/app/src/utils/spanAttributeReaders.ts diff --git a/.changeset/otel-semconv-http.md b/.changeset/otel-semconv-http.md new file mode 100644 index 0000000000..96f7da462f --- /dev/null +++ b/.changeset/otel-semconv-http.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +fix: Read current OpenTelemetry HTTP and RPC semantic conventions (with deprecated names as fallbacks) in the span side panel and trace waterfall: redesign the HTTP panel around a request line and details, add a gRPC/RPC call panel, and flag correlated log errors and warnings by SeverityNumber or case-insensitive SeverityText diff --git a/packages/app/src/LogSidePanelElements.tsx b/packages/app/src/LogSidePanelElements.tsx index 66bc9b9125..484e890ffc 100644 --- a/packages/app/src/LogSidePanelElements.tsx +++ b/packages/app/src/LogSidePanelElements.tsx @@ -24,6 +24,7 @@ import { ColumnDef, Row, Table } from '@tanstack/react-table'; import HyperJson from './components/HyperJson'; import { StacktraceFrame as StacktraceFrameCmp } from './components/StacktraceFrame'; import { TableCellButton } from './components/Table'; +import type { HttpHeader } from './utils/httpHeaders'; import { UNDEFINED_WIDTH } from './tableUtils'; import type { StacktraceBreadcrumb, StacktraceFrame } from './types'; import { FormatTime } from './useFormatTime'; @@ -375,20 +376,20 @@ const useShowMoreRows = ({ /** * Request / Response Headers elements */ -export const headerColumns: ColumnDef<[string, string]>[] = [ +export const headerColumns: ColumnDef[] = [ { - accessorKey: '0', + accessorKey: 'name', header: 'Header', size: 260, cell: ({ row }) => ( -
- {row.original[0]} +
+ {row.original.name}
), }, { size: UNDEFINED_WIDTH, - accessorKey: '1', + accessorKey: 'value', header: 'Value', }, ]; @@ -396,27 +397,6 @@ export const headerColumns: ColumnDef<[string, string]>[] = [ /** * Network Subpanel */ -export const networkColumns: ColumnDef<{ - label: string; - value: string; - className?: string; -}>[] = [ - { - accessorKey: 'label', - header: 'Label', - size: 260, - cell: ({ row }) => {row.original.label}, - }, - { - size: UNDEFINED_WIDTH, - accessorKey: 'value', - header: 'Value', - cell: ({ row }) => ( - {row.original.value} - ), - }, -]; - export const NetworkBody = ({ body, theme, @@ -461,7 +441,7 @@ export const NetworkBody = ({ <> {body != null && body != '' ? (
 {
   });
 });
 
+describe('getLogSeverityLevel', () => {
+  it.each([
+    [12, 'error', undefined],
+    [13, undefined, 'warn'],
+    [16, 'error', 'warn'],
+    [17, 'info', 'error'],
+    ['17', undefined, 'error'],
+    [0, 'ERROR', 'error'],
+    [undefined, 'Warning', 'warn'],
+    ['', 'critical', 'error'],
+    [undefined, 'info', undefined],
+  ])(
+    'maps SeverityNumber %p and SeverityText %p to %p',
+    (severityNumber, severityText, expected) => {
+      expect(getLogSeverityLevel({ severityNumber, severityText })).toBe(
+        expected,
+      );
+    },
+  );
+});
+
 describe('getColorFromCSSToken', () => {
   afterEach(() => {
     jest.restoreAllMocks();
diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx
index 47d4eda692..c9acc5c42b 100644
--- a/packages/app/src/components/DBRowOverviewPanel.tsx
+++ b/packages/app/src/components/DBRowOverviewPanel.tsx
@@ -8,6 +8,7 @@ import { WithClause } from '@/hooks/useRowWhere';
 import { getEventBody } from '@/source';
 import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes';
 import { resolveRowTimestampAnchor } from '@/utils/rowTimestamps';
+import { getRequestPanelTitle } from '@/utils/rpcSpanAttributes';
 
 import {
   getJSONColumnNames,
@@ -134,25 +135,10 @@ export function RowOverviewPanel({
     [generateSearchUrl],
   );
 
-  const isHttpRequest = useMemo(() => {
-    const attributes =
-      eventAttributesExpr && dataAttributes?.[eventAttributesExpr];
-    return attributes?.['http.url'] != null;
-  }, [dataAttributes, eventAttributesExpr]);
-
-  const filteredEventAttributes = useMemo(() => {
-    if (!eventAttributesExpr) return dataAttributes;
-
-    const attributes = dataAttributes?.[eventAttributesExpr];
-    return isHttpRequest && attributes
-      ? {
-          [eventAttributesExpr]: pickBy(
-            attributes,
-            (_, key) => !key.startsWith('http.'),
-          ),
-        }
-      : dataAttributes;
-  }, [dataAttributes, isHttpRequest, eventAttributesExpr]);
+  const requestPanelTitle = useMemo(
+    () => getRequestPanelTitle(flattenedEventAttributes),
+    [flattenedEventAttributes],
+  );
 
   const exceptionValues = useMemo(() => {
     const parsedEvents =
@@ -266,17 +252,18 @@ export function RowOverviewPanel({
         multiple
         variant="noPadding"
       >
-        {isHttpRequest && (
+        {requestPanelTitle != null && (
           
             
               
-                HTTP Request
+                {requestPanelTitle}
               
             
             
               
                 
               
             
@@ -323,7 +310,7 @@ export function RowOverviewPanel({
           
         )}
 
-        {Object.keys(filteredEventAttributes).length > 0 && (
+        {Object.keys(dataAttributes).length > 0 && (
           
             
               
@@ -333,7 +320,7 @@ export function RowOverviewPanel({
             
               
                 
diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx
index 35e116764f..bc8c4c3748 100644
--- a/packages/app/src/components/DBTraceWaterfallChart.tsx
+++ b/packages/app/src/components/DBTraceWaterfallChart.tsx
@@ -57,6 +57,7 @@ import {
   TRACE_WATERFALL_FETCH_LIMIT,
   TRACE_WATERFALL_ROW_LIMIT,
 } from '@/components/traceWaterfallLimits';
+import { useColumns } from '@/hooks/useMetadata';
 import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
 import useRowWhere, { WithClause } from '@/hooks/useRowWhere';
 import useWaterfallSearchState from '@/hooks/useWaterfallSearchState';
@@ -75,12 +76,15 @@ import {
   getChartColorSuccess,
   getChartColorSuccessHighlight,
   getChartColorWarning,
+  getLogSeverityLevel,
   parseTimestampToMs,
 } from '@/utils';
 import {
   getHighlightedAttributesFromData,
   getSelectExpressionsForHighlightedAttributes,
 } from '@/utils/highlightedAttributes';
+import { getHttpUrl } from '@/utils/httpUrl';
+import { isRpcSpan } from '@/utils/rpcSpanAttributes';
 
 import { DBHighlightedAttributesList } from './DBHighlightedAttributesList';
 
@@ -95,6 +99,7 @@ export type SpanRow = {
   StatusCode?: string;
   ServiceName?: string;
   SeverityText?: string;
+  SeverityNumber?: number;
   HyperDXEventType: 'span';
   type?: string;
   SpanAttributes?: Record;
@@ -141,6 +146,20 @@ function barColor(condition: {
   return isHighlighted ? '#A9AFB7' : '#6A7077';
 }
 
+// Shared by the error count and the row icons so the two can't disagree.
+// OTel spans have no severity; they report errors through StatusCode.
+function getRowSeverity(
+  row: Pick,
+): 'error' | 'warn' | undefined {
+  if (row.type === SourceKind.Log) {
+    return getLogSeverityLevel({
+      severityNumber: row.SeverityNumber,
+      severityText: row.SeverityText,
+    });
+  }
+  return row.StatusCode === 'Error' ? 'error' : undefined;
+}
+
 // Per-service span palette: the full categorical palette minus the two hues we
 // reserve for semantics — green (correlated log rows) and red (error spans) —
 // so a service color is never confused with a log or an error. Index-aligned
@@ -195,6 +214,7 @@ function getConfig(
   traceId: string,
   hiddenRowExpression?: string,
   hiddenRowExpressionLanguage: 'lucene' | 'sql' = 'lucene',
+  selectSeverityNumber = false,
 ) {
   const alias: Record = {
     Body: getTableBody(source),
@@ -328,6 +348,14 @@ function getConfig(
               },
             ]
           : []),
+        ...(selectSeverityNumber
+          ? [
+              {
+                valueExpression: 'SeverityNumber',
+                alias: 'SeverityNumber',
+              },
+            ]
+          : []),
       ],
     );
   }
@@ -371,6 +399,7 @@ export function useEventsAroundFocus({
   enabled,
   hiddenRowExpression,
   hiddenRowExpressionLanguage = 'lucene',
+  selectSeverityNumber = false,
 }: {
   tableSource: TTraceSource | TLogSource;
   focusDate: Date;
@@ -380,6 +409,7 @@ export function useEventsAroundFocus({
   /** An expression (in `hiddenRowExpressionLanguage`) that identifies rows to be hidden. Hidden rows will be returned with a `__hdx_hidden: true` column. */
   hiddenRowExpression?: string;
   hiddenRowExpressionLanguage?: 'lucene' | 'sql';
+  selectSeverityNumber?: boolean;
 }) {
   const { config, alias, type } = useMemo(
     () =>
@@ -388,8 +418,15 @@ export function useEventsAroundFocus({
         traceId,
         hiddenRowExpression,
         hiddenRowExpressionLanguage,
+        selectSeverityNumber,
       ),
-    [tableSource, traceId, hiddenRowExpression, hiddenRowExpressionLanguage],
+    [
+      tableSource,
+      traceId,
+      hiddenRowExpression,
+      hiddenRowExpressionLanguage,
+      selectSeverityNumber,
+    ],
   );
 
   const {
@@ -445,10 +482,16 @@ export function useEventsAroundFocus({
     // DO NOT REMOVE, useRowWhere will error if no meta
     if (!meta || meta.length === 0) return [];
     return [...trimmedBefore, ...trimmedAfter].map(cd => {
-      // Omit SpanAttributes, SpanEvents and __hdx_hidden from rowWhere id generation.
+      // Omit SpanAttributes, SpanEvents, __hdx_hidden and SeverityNumber from rowWhere id generation.
       // SpanAttributes and SpanEvents can be large objects, and __hdx_hidden may be a lucene expression.
+      // SeverityNumber is only selected when the column exists; leaving it out keeps row ids stable.
       const rowWhereResult = getRowWhere(
-        omit(cd, ['SpanAttributes', 'SpanEvents', '__hdx_hidden']),
+        omit(cd, [
+          'SpanAttributes',
+          'SpanEvents',
+          '__hdx_hidden',
+          'SeverityNumber',
+        ]),
       );
 
       return {
@@ -735,6 +778,18 @@ export function DBTraceWaterfallChartContainer({
     hiddenRowExpressionLanguage: traceFilterLanguage,
     enabled: true,
   });
+
+  const { data: logColumns, isLoading: isLogColumnsLoading } = useColumns(
+    {
+      databaseName: logTableSource?.from.databaseName ?? '',
+      tableName: logTableSource?.from.tableName ?? '',
+      connectionId: logTableSource?.connection ?? '',
+    },
+    { retry: 1 },
+  );
+  const hasSeverityNumber =
+    logColumns?.some(column => column.name === 'SeverityNumber') ?? false;
+
   const {
     rows: logRowsData,
     isFetching: logIsFetching,
@@ -750,10 +805,13 @@ export function DBTraceWaterfallChartContainer({
     traceId,
     hiddenRowExpression: logWhere ? `NOT (${logWhere})` : undefined,
     hiddenRowExpressionLanguage: logFilterLanguage,
-    enabled: logTableSource ? true : false, // disable fire query if logSource is not exist
+    // Wait for the columns (a failed lookup runs without SeverityNumber) so the
+    // select doesn't change mid-flight and run the log query twice.
+    enabled: !!logTableSource && !isLogColumnsLoading,
+    selectSeverityNumber: hasSeverityNumber,
   });
 
-  const isFetching = traceIsFetching || logIsFetching;
+  const isFetching = traceIsFetching || logIsFetching || isLogColumnsLoading;
   // Only a fatal trace failure (bad source / connection) blanks the waterfall.
   // A *filter* error on either side is non-fatal: the offending query falls back
   // to unfiltered data and the error is surfaced inline next to its input, so a
@@ -1127,9 +1185,7 @@ export function DBTraceWaterfallChartContainer({
     node => node.type === SourceKind.Log,
   ).length;
   const errorCount = visibleNodes.filter(
-    node =>
-      node.StatusCode === 'Error' ||
-      node.SeverityText?.toLowerCase() === 'error',
+    node => getRowSeverity(node) === 'error',
   ).length;
 
   const countParts: string[] = [];
@@ -1178,14 +1234,11 @@ export function DBTraceWaterfallChartContainer({
           );
         }
 
-        // Extract HTTP-related logic
-        const eventAttributes = result.SpanAttributes || {};
-        const hasHttpAttributes =
-          eventAttributes['http.url'] || eventAttributes['http.method'];
-        const httpUrl = eventAttributes['http.url'];
-
-        const displayText =
-          hasHttpAttributes && httpUrl ? `${body} ${httpUrl}` : body;
+        const spanAttributes = result.SpanAttributes ?? {};
+        const httpUrl = isRpcSpan(spanAttributes)
+          ? undefined
+          : getHttpUrl(spanAttributes);
+        const displayText = httpUrl ? `${body} ${httpUrl}` : body;
 
         // Process span events into markers (only if showSpanEvents is enabled)
         const markers =
@@ -1200,10 +1253,10 @@ export function DBTraceWaterfallChartContainer({
         // Extract status logic
         // TODO: Legacy schemas will have STATUS_CODE_ERROR
         // See: https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/34799/files#diff-1ec84547ed93f2c8bfb21c371ca0b5304f01371e748d4b02bf397313a4b1dfa4L197
-        const isError =
-          result.StatusCode == 'Error' || result.SeverityText === 'error';
+        const severity = getRowSeverity(result);
+        const isError = severity === 'error';
         const status = result.StatusCode || result.SeverityText;
-        const isWarn = result.SeverityText === 'warn';
+        const isWarn = severity === 'warn';
         const isHighlighted = highlightedRowWhere === id;
 
         const barBackgroundColor =
@@ -1316,7 +1369,7 @@ export function DBTraceWaterfallChartContainer({
                     size="xxs"
                     truncate="end"
                     span
-                    title={`${serviceName}${hasHttpAttributes && httpUrl ? ` ${displayText}` : ''}`}
+                    title={`${serviceName}${httpUrl ? ` ${displayText}` : ''}`}
                     role="button"
                   >
                     {serviceName && <>{serviceName} }
diff --git a/packages/app/src/components/HttpMessageSections.tsx b/packages/app/src/components/HttpMessageSections.tsx
new file mode 100644
index 0000000000..c0acc5d10e
--- /dev/null
+++ b/packages/app/src/components/HttpMessageSections.tsx
@@ -0,0 +1,129 @@
+import upperFirst from 'lodash/upperFirst';
+import type { ReactNode } from 'react';
+
+import { Table } from '@/components/Table';
+import {
+  CollapsibleSection,
+  headerColumns,
+  NetworkBody,
+  SectionWrapper,
+} from '@/LogSidePanelElements';
+import {
+  HTTP_REQUEST_HEADER_PREFIX,
+  HTTP_RESPONSE_HEADER_PREFIX,
+  HttpHeader,
+  parseHttpHeaders,
+  toHeaderDisplayCase,
+} from '@/utils/httpHeaders';
+import { getHttpBody, getHttpBodyTruncation } from '@/utils/httpSpanAttributes';
+import type { Attributes } from '@/utils/spanAttributeReaders';
+
+const JSON_TREE_THEME = {
+  base00: '#00000000',
+  base01: '#383830',
+  base02: '#49483e',
+  base03: '#75715e',
+  base04: '#a59f85',
+  base05: '#f8f8f2',
+  base06: '#f5f4f1',
+  base07: '#f9f8f5',
+  base08: '#f92672',
+  base09: '#fd971f',
+  base0A: '#f4bf75',
+  base0B: '#a6e22e',
+  base0C: '#a1efe4',
+  base0D: '#8378FF',
+  base0E: '#ae81ff',
+  base0F: '#cc6633',
+};
+
+/** A collapsed table of headers or RPC metadata; hidden when empty. */
+export function HeaderSection({
+  title,
+  headers,
+}: {
+  title: string;
+  headers: HttpHeader[];
+}) {
+  if (headers.length === 0) {
+    return null;
+  }
+  return (
+    
+      
+        
+      
+    
+  );
+}
+
+function BodySection({
+  attributes,
+  direction,
+}: {
+  attributes: Attributes;
+  direction: 'request' | 'response';
+}) {
+  const body = getHttpBody(attributes, direction);
+  if (body == null) {
+    return null;
+  }
+  const title = `${upperFirst(direction)} body`;
+  const truncation = getHttpBodyTruncation(attributes, direction);
+  return (
+    
+      
+        
+      
+    
+  );
+}
+
+/**
+ * HTTP headers and bodies, each in a collapsible section, after any
+ * `children` sections (e.g. RPC metadata).
+ */
+export function HttpMessageSections({
+  attributes,
+  children,
+}: {
+  attributes: Attributes;
+  children?: ReactNode;
+}) {
+  return (
+    // A block wrapper so the sections' margins collapse inside a flex Stack.
+    
+ {children} + + + + +
+ ); +} diff --git a/packages/app/src/components/NetworkPropertyPanel.stories.tsx b/packages/app/src/components/NetworkPropertyPanel.stories.tsx new file mode 100644 index 0000000000..d07c968c26 --- /dev/null +++ b/packages/app/src/components/NetworkPropertyPanel.stories.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; + +const meta = { + title: 'Components/NetworkPropertyPanel', + component: NetworkPropertySubpanel, + parameters: { layout: 'padded' }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const semconvClientErrorAttributes = { + 'url.full': + 'https://example-api.clickhouse.com/v2/charges/ch_3Nq8xY2eZvKYlo2C1a9b/refunds?expand[]=customer&expand[]=invoice.subscription&idempotency_key=7f9c1a2e-4b3d-4e8f-9a6b-2c1d0e9f8a7b&locale=en-US&return_url=https%3A%2F%2Fshop.example.com%2Fcheckout%2Fcomplete%3Forder%3D84213', + 'http.request.method': 'POST', + 'http.response.status_code': '429', + 'network.peer.address': '10.0.4.12', + 'network.peer.port': '443', + 'network.protocol.version': '2', + 'server.address': 'example-api.clickhouse.com', + 'server.port': '443', + 'http.request.resend_count': '2', + 'error.type': '429', + 'http.request.body.size': '512', + 'http.response.body.size': '87', + 'http.request.header.content-type': '["application/json"]', + 'http.request.header.x_forwarded_for': '1.2.3.4', + 'http.response.header.retry-after': '["30"]', + 'http.request.body.content': + '{"amount":4200,"currency":"usd","reason":"requested_by_customer","metadata":{"order_id":"84213"}}', + 'http.response.body.content': + '{"error":{"type":"rate_limit_error","message":"Too many requests"}}', +}; + +export const SemconvServer: Story = { + args: { + eventAttributes: { + 'client.address': '::1', + 'http.request.method': 'GET', + 'http.response.status_code': '200', + 'http.route': '/api/hello', + 'network.peer.address': '::1', + 'network.peer.port': '49347', + 'network.protocol.version': '1.1', + 'server.address': 'localhost', + 'server.port': '3001', + 'url.path': '/api/hello', + 'url.scheme': 'http', + 'user_agent.original': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36', + }, + }, +}; + +export const SemconvClientError: Story = { + args: { eventAttributes: semconvClientErrorAttributes }, +}; + +export const ServerError: Story = { + args: { + eventAttributes: { + 'http.request.method': 'GET', + 'http.response.status_code': '503', + 'http.route': '/orders/{id}', + 'url.path': '/orders/42', + 'url.query': 'expand=items', + 'url.scheme': 'https', + 'server.address': 'example-api.clickhouse.com', + 'server.port': '443', + 'client.address': '203.0.113.9', + 'network.peer.address': '10.0.0.5', + 'network.peer.port': '38122', + 'network.protocol.version': '1.1', + 'user_agent.original': 'okhttp/4.12.0', + 'error.type': '_OTHER', + 'http.request.header.accept': '["application/json"]', + 'http.request.header.x-request-id': + '["5d0c7f3e-2a1b-4c9d-8e7f-6a5b4c3d2e1f"]', + 'http.response.header.content-type': '["application/json"]', + 'http.response.header.retry-after': '["5"]', + }, + }, +}; + +export const DeprecatedClient: Story = { + args: { + eventAttributes: { + 'http.url': 'http://example-api.clickhouse.com:8080/api/items/9921', + 'http.method': 'GET', + 'http.status_code': '404', + 'http.status_text': 'Not Found', + 'net.peer.ip': '10.0.0.2', + 'net.peer.port': '8080', + 'http.flavor': '1.1', + 'http.user_agent': 'python-requests/2.32.3', + }, + }, +}; + +export const DeprecatedServer: Story = { + args: { + eventAttributes: { + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.host': 'frontend-proxy:8080', + 'http.target': '/api/products/0PUK6V6EV0?currency=USD', + 'http.status_code': '200', + 'http.status_text': 'OK', + 'net.host.name': 'frontend-proxy', + 'net.host.port': '8080', + 'net.sock.peer.addr': '10.116.11.86', + 'net.sock.peer.port': '51514', + 'http.flavor': '2.0', + 'http.user_agent': 'python-requests/2.32.3', + 'net.transport': 'ip_tcp', + }, + }, +}; + +export const FailedBrowserRequest: Story = { + args: { + eventAttributes: { + 'http.url': 'https://example-api.clickhouse.com/v1/cart', + 'http.method': 'POST', + 'http.status_code': '0', + 'http.request.body': '{"items":[1,2]}', + }, + }, +}; + +// The ClickStack Node and Python SDKs keep the first 8192 bytes of a body and +// report the full size in http.*.body.size. +export const TruncatedBody: Story = { + args: { + eventAttributes: { + 'http.request.method': 'GET', + 'http.response.status_code': '200', + 'http.route': '/api/products', + 'url.path': '/api/products', + 'url.scheme': 'http', + 'server.address': 'catalog', + 'server.port': '8080', + 'http.response.body.size': '23817', + 'http.response.body.content': JSON.stringify({ + products: Array.from({ length: 200 }, (_, i) => ({ + id: `SKU-${String(i + 1).padStart(4, '0')}`, + name: `Product ${i + 1}`, + priceUsd: { currencyCode: 'USD', units: 10 + i, nanos: 990000000 }, + })), + }).slice(0, 8192), + }, + }, +}; + +export const Narrow: Story = { + args: { eventAttributes: semconvClientErrorAttributes }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; diff --git a/packages/app/src/components/NetworkPropertyPanel.tsx b/packages/app/src/components/NetworkPropertyPanel.tsx index 741d8c4607..3096dc3f87 100644 --- a/packages/app/src/components/NetworkPropertyPanel.tsx +++ b/packages/app/src/components/NetworkPropertyPanel.tsx @@ -1,307 +1,140 @@ -import React, { useMemo } from 'react'; -import { pickBy } from 'lodash'; -import CopyToClipboard from 'react-copy-to-clipboard'; -import { JSONTree } from 'react-json-tree'; -import { Button } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { IconTerminal } from '@tabler/icons-react'; +import { ReactNode } from 'react'; +import { Badge } from '@clickhouse/click-ui'; +import { Group, Stack } from '@mantine/core'; -import HyperJson from '@/components/HyperJson'; -import { Table } from '@/components/Table'; -import { - headerColumns, - networkColumns, - SectionWrapper, -} from '@/LogSidePanelElements'; -import { CollapsibleSection } from '@/LogSidePanelElements'; import { CurlGenerator } from '@/utils/curlGenerator'; - -interface NetworkPropertyPanelProps { - eventAttributes: Record; +import { + getReplayableHeaders, + HTTP_REQUEST_HEADER_PREFIX, + parseHttpHeaders, + toHeaderDisplayCase, +} from '@/utils/httpHeaders'; +import { + getHttpBody, + getHttpMessageSize, + getHttpMethod, + getHttpProtocol, + getHttpResendCount, + getHttpRoute, + getHttpStatusCode, + getHttpStatusText, + getUserAgent, +} from '@/utils/httpSpanAttributes'; +import { getHttpUrl, isAbsoluteHttpUrl } from '@/utils/httpUrl'; +import { isRpcSpan } from '@/utils/rpcSpanAttributes'; +import { + Attributes, + firstSetString, + getClientAddress, + getLocalAddress, + getNetworkPeerAddress, +} from '@/utils/spanAttributeReaders'; + +import { HttpMessageSections } from './HttpMessageSections'; +import { RequestDetailRow, RequestDetailsTable } from './RequestDetailsTable'; +import { getHttpStatusBadge, RequestLine } from './RequestLine'; +import { RpcCallSubpanel } from './RpcCallPanel'; + +function buildCurl(attributes: Attributes, url: string, method?: string) { + const body = getHttpBody(attributes, 'request'); + return CurlGenerator({ + method, + headers: getReplayableHeaders( + parseHttpHeaders(attributes, HTTP_REQUEST_HEADER_PREFIX), + ) + .map(toHeaderDisplayCase) + .map(h => [h.name, h.value]), + url, + body: + typeof body === 'string' || (typeof body === 'object' && body !== null) + ? body + : undefined, + }); } -// https://github.com/reduxjs/redux-devtools/blob/f11383d294c1139081f119ef08aa1169bd2ad5ff/packages/react-json-tree/src/createStylingFromTheme.ts -const JSON_TREE_THEME = { - base00: '#00000000', - base01: '#383830', - base02: '#49483e', - base03: '#75715e', - base04: '#a59f85', - base05: '#f8f8f2', - base06: '#f5f4f1', - base07: '#f9f8f5', - base08: '#f92672', - base09: '#fd971f', - base0A: '#f4bf75', - base0B: '#a6e22e', - base0C: '#a1efe4', - base0D: '#8378FF', // Value Labels - base0E: '#ae81ff', - base0F: '#cc6633', -}; - -const parseHeaders = ( - keyPrefix: string, - parsedProperties: any, -): [string, string][] => { - const reqHeaderObj: Record< - string, - string | string[] | Record - > = pickBy(parsedProperties, (value, key) => key.startsWith(keyPrefix)); - - return Object.entries(reqHeaderObj).flatMap(([fullKey, _value]) => { - let value = _value; - try { - if (typeof _value === 'string') { - value = JSON.parse(_value); - } - } catch { - // ignore - } - - // Replacing _ -> - is part of the otel spec, idk why - const key = fullKey.replace(keyPrefix, '').replace('_', '-'); - - let keyVal = [[key, `${value}`]] as [string, string][]; - - if (Array.isArray(value)) { - keyVal = value.map(value => [key, `${value}`] as [string, string]); - } else if (typeof value === 'object' && Object.keys(value).length > 0) { - try { - // TODO: We actually shouldn't be re-serializing this as it may mess with the original value - keyVal = [[key, `${JSON.stringify(value)}`]] as [string, string][]; - } catch (e) { - console.error(e); - } - } - - return keyVal; - }); -}; +function userAgentValue(attributes: Attributes): ReactNode { + const userAgent = getUserAgent(attributes); + const syntheticType = firstSetString(attributes, [ + 'user_agent.synthetic.type', + ]); + if (userAgent == null || syntheticType == null) { + return userAgent; + } + return ( + + {userAgent} + + + ); +} -const NetworkBody = ({ - body, - theme, - emptyMessage, - notCollectedMessage, -}: { - body: any; - theme?: any; - emptyMessage?: string; - notCollectedMessage?: string; -}) => { - const valueRenderer = React.useCallback((raw: any) => { - return ( -
-        {raw}
-      
- ); - }, []); +function getDetailRows( + attributes: Attributes, + statusCode: number | undefined, +): RequestDetailRow[] { + const errorType = firstSetString(attributes, ['error.type']); + const resends = getHttpResendCount(attributes); + return [ + ['Route', getHttpRoute(attributes)], + // Instrumentations set error.type to the status code, which the badge + // already shows. + ['Error type', errorType !== String(statusCode) ? errorType : undefined], + ['Protocol', getHttpProtocol(attributes)], + ['Remote address', getNetworkPeerAddress(attributes)], + ['Client address', getClientAddress(attributes)], + ['Local address', getLocalAddress(attributes)], + ['User agent', userAgentValue(attributes)], + ['Request size', getHttpMessageSize(attributes, 'request')], + ['Response size', getHttpMessageSize(attributes, 'response')], + ['Resends', resends != null && resends > 0 ? resends : undefined], + ]; +} - const parsedBody = React.useMemo(() => { - if (typeof body !== 'string') return null; - try { - if ( - (body.startsWith('{') && body.endsWith('}')) || - (body.startsWith('[') && body.endsWith(']')) - ) { - const parsed = JSON.parse(body); - return parsed; - } - } catch { - return null; - } - }, [body]); +function HttpRequestSubpanel({ attributes }: { attributes: Attributes }) { + const url = getHttpUrl(attributes); + const statusCode = getHttpStatusCode(attributes); + const method = getHttpMethod(attributes); return ( - <> - {body != null && body != '' ? ( -
-          {parsedBody ? (
-            
-          ) : typeof body === 'string' ? (
-            body
-          ) : (
-            
-          )}
-        
- ) : body === '' ? ( -
{emptyMessage}
- ) : ( -
{notCollectedMessage}
- )} - + + buildCurl(attributes, url, method) + : undefined + } + /> + + + ); -}; +} +/** + * Spans with both RPC and HTTP attributes (gRPC over HTTP/2, e.g. ASP.NET + * Core) get the RPC panel: their HTTP status is almost always 200 and the + * call's result is the RPC status. + */ export function NetworkPropertySubpanel({ eventAttributes, -}: NetworkPropertyPanelProps) { - const requestHeaders = useMemo( - () => parseHeaders('http.request.header.', eventAttributes), - [eventAttributes], - ); - - const responseHeaders = useMemo( - () => parseHeaders('http.response.header.', eventAttributes), - [eventAttributes], - ); - - const url = eventAttributes['http.url']; - const remoteAddress = eventAttributes['net.peer.ip']; - const statusCode = eventAttributes['http.status_code']; - const method = eventAttributes['http.method']; - const requestBody = eventAttributes['http.request.body']; - const responseBody = eventAttributes['http.response.body']; - - const curl = CurlGenerator({ - method, - headers: requestHeaders, - url, - body: requestBody, - }); - - if (!url && !method && !statusCode) { - return null; - } - - return ( -
-
- { - notifications.show({ - color: 'green', - message: 'Curl command copied to clipboard', - }); - }} - > - - - {/* - - */} -
- - -
= 500 - ? 'text-danger' - : statusCode >= 400 - ? 'text-warning' - : 'text-success', - }, - ].filter(Boolean)} - hideHeader - /> - - - {requestHeaders.length > 0 && ( - - -
- - - )} - - {requestBody != null && ( - - - - - - )} - {responseHeaders.length > 0 && ( - - -
- - - )} - {responseBody != null && ( - - - - - - )} - + spanKind, +}: { + eventAttributes: Attributes; + spanKind?: string; +}) { + return isRpcSpan(eventAttributes) ? ( + + ) : ( + ); } diff --git a/packages/app/src/components/RequestDetailsTable.tsx b/packages/app/src/components/RequestDetailsTable.tsx new file mode 100644 index 0000000000..fa01d739b7 --- /dev/null +++ b/packages/app/src/components/RequestDetailsTable.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { Table } from '@mantine/core'; + +export type RequestDetailRow = [label: string, value: ReactNode]; + +export function RequestDetailsTable({ rows }: { rows: RequestDetailRow[] }) { + const visibleRows = rows.filter(([, value]) => value != null && value !== ''); + if (visibleRows.length === 0) { + return null; + } + + return ( +
+ + {visibleRows.map(([label, value]) => ( + + + {label} + + {value} + + ))} + +
+ ); +} diff --git a/packages/app/src/components/RequestLine.tsx b/packages/app/src/components/RequestLine.tsx new file mode 100644 index 0000000000..1b7a82656b --- /dev/null +++ b/packages/app/src/components/RequestLine.tsx @@ -0,0 +1,127 @@ +import type { CSSProperties } from 'react'; +import { Badge, type BadgeProps } from '@clickhouse/click-ui'; +import { Button, Group, Text } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconTerminal } from '@tabler/icons-react'; + +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; +import type { RpcStatus } from '@/utils/rpcSpanAttributes'; + +type RequestStatusBadge = { text: string; state: BadgeProps['state'] }; + +const NO_SHRINK = { flexShrink: 0 }; +const BUTTON_STYLES = { label: { alignSelf: 'baseline' } }; +const TARGET_STYLE: CSSProperties = { + flex: '1 1 16rem', + minWidth: 0, + maxWidth: 'max-content', + overflowWrap: 'anywhere', +}; + +// The legacy HyperDX browser SDK reports 0 when the request never got a +// response (network error, CORS, aborted). +export function formatHttpStatus( + statusCode: number, + statusText?: string, +): string { + const text = statusText || (statusCode === 0 ? 'No response' : undefined); + return text ? `${statusCode} ${text}` : `${statusCode}`; +} + +function httpStatusState(statusCode: number): BadgeProps['state'] { + if (statusCode >= 500 || statusCode === 0) return 'danger'; + if (statusCode >= 400) return 'warning'; + if (statusCode >= 200 && statusCode < 300) return 'success'; + if (statusCode >= 100 && statusCode < 400) return 'info'; + return 'neutral'; +} + +export function getHttpStatusBadge( + statusCode: number, + statusText?: string, +): RequestStatusBadge { + return { + text: formatHttpStatus(statusCode, statusText), + state: httpStatusState(statusCode), + }; +} + +/** + * Falls back to an "Error" badge: some emitters record an error without a + * status code. + */ +export function getRpcStatusBadge( + status: RpcStatus | undefined, + hasError: boolean, +): RequestStatusBadge | undefined { + if (status != null) { + return { text: status.label, state: status.isOk ? 'success' : 'danger' }; + } + return hasError ? { text: 'Error', state: 'danger' } : undefined; +} + +async function copyCurl(curl: string) { + if (!(await copyTextToClipboard(curl))) { + notifications.show({ color: 'red', message: CLIPBOARD_ERROR_MESSAGE }); + return; + } + notifications.show({ + color: 'green', + message: 'cURL command copied to clipboard', + }); +} + +/** + * `METHOD url status` for HTTP, `system method status` for RPC, read like an + * access log line. + */ +export function RequestLine({ + label, + target, + status, + getCurl, +}: { + /** HTTP method or RPC system. */ + label?: string; + /** URL or RPC method. */ + target?: string; + status?: RequestStatusBadge; + /** Omit to hide the copy action, e.g. when curl can't replay the call. */ + getCurl?: () => string; +}) { + return ( + + {label != null && ( + + )} + {target != null && ( + + {target} + + )} + {status != null && ( + + )} + {getCurl != null && ( + + )} + + ); +} diff --git a/packages/app/src/components/RpcCallPanel.stories.tsx b/packages/app/src/components/RpcCallPanel.stories.tsx new file mode 100644 index 0000000000..bdb6c7c840 --- /dev/null +++ b/packages/app/src/components/RpcCallPanel.stories.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; + +// Rendered through NetworkPropertySubpanel, the overview's entry point, so +// spans with both RPC and HTTP attributes take the same path as in the app. +// The attributes come from real OTel Node and ASP.NET Core spans. +const meta = { + title: 'Components/RpcCallPanel', + component: NetworkPropertySubpanel, + parameters: { layout: 'padded' }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const nodeClientAttributes = { + 'rpc.method': 'GetQuote', + 'rpc.response.metadata.x_quote_source': '["pricing-v2"]', + 'rpc.service': 'demo.QuoteService', + 'rpc.system': 'grpc', + 'server.address': '127.0.0.1', + 'server.port': '3002', +}; + +export const GrpcNodeClientOk: Story = { + args: { + eventAttributes: { + ...nodeClientAttributes, + 'rpc.grpc.status_code': '0', + 'rpc.request.metadata.x_request_id': + '["f8f85da1-5fa7-4baa-a66f-69d521029ff9"]', + }, + }, +}; + +export const GrpcNodeClientError: Story = { + args: { + eventAttributes: { + ...nodeClientAttributes, + 'grpc.error_message': '5 NOT_FOUND: no quote for missing', + 'grpc.error_name': 'Error', + 'rpc.grpc.status_code': '5', + 'rpc.request.metadata.x_request_id': + '["cb88b510-b001-4477-9f37-7ce1d8299b97"]', + }, + }, +}; + +export const GrpcNodeServerErrorWithHttp: Story = { + args: { + eventAttributes: { + ...nodeClientAttributes, + 'grpc.error_message': 'no quote for missing', + 'grpc.error_name': 'Error', + 'http.request.header.content-type': '["application/grpc"]', + 'http.request.method': 'POST', + 'http.response.status_code': '200', + 'http.route': '/demo.QuoteService/GetQuote', + 'network.protocol.name': 'http', + 'network.protocol.version': '2', + 'rpc.grpc.status_code': '5', + 'rpc.request.metadata.x_request_id': + '["cb88b510-b001-4477-9f37-7ce1d8299b97"]', + 'url.path': '/demo.QuoteService/GetQuote', + 'url.scheme': 'http', + 'user_agent.original': 'grpc-node-js/1.14.6', + }, + }, +}; + +export const GrpcAspNetCoreServer: Story = { + args: { + eventAttributes: { + 'grpc.method': '/demo.QuoteService/GetQuote', + 'grpc.status_code': '0', + 'http.request.method': 'POST', + 'http.response.status_code': '200', + 'http.route': '/demo.QuoteService/GetQuote', + 'network.protocol.version': '2', + 'server.address': 'quote', + 'server.port': '8080', + 'url.path': '/demo.QuoteService/GetQuote', + 'url.scheme': 'http', + 'user_agent.original': + 'grpc-dotnet/2.67.0 (.NET 9.0.0; CLR 9.0.0; net9.0; osx; arm64)', + }, + }, +}; diff --git a/packages/app/src/components/RpcCallPanel.tsx b/packages/app/src/components/RpcCallPanel.tsx new file mode 100644 index 0000000000..709cc3656f --- /dev/null +++ b/packages/app/src/components/RpcCallPanel.tsx @@ -0,0 +1,109 @@ +import { Stack } from '@mantine/core'; + +import { + getHttpMethod, + getHttpProtocol, + getHttpRoute, + getHttpStatusCode, + getHttpStatusText, + getUserAgent, +} from '@/utils/httpSpanAttributes'; +import { getHttpUrl } from '@/utils/httpUrl'; +import { + getRpcMetadata, + getRpcMethod, + getRpcServerAddress, + getRpcStatus, + getRpcSystemLabel, + RpcStatus, +} from '@/utils/rpcSpanAttributes'; +import { + Attributes, + firstSetString, + getNetworkPeerAddress, +} from '@/utils/spanAttributeReaders'; + +import { HeaderSection, HttpMessageSections } from './HttpMessageSections'; +import { RequestDetailRow, RequestDetailsTable } from './RequestDetailsTable'; +import { + formatHttpStatus, + getRpcStatusBadge, + RequestLine, +} from './RequestLine'; + +/** HTTP facts of gRPC over HTTP, e.g. `POST http://cart/x.Cart/Get · 200 OK`. */ +function getHttpSummary(attributes: Attributes): string | undefined { + const statusCode = getHttpStatusCode(attributes); + const request = [getHttpMethod(attributes), getHttpUrl(attributes)] + .filter(part => part != null) + .join(' '); + const status = + statusCode != null + ? formatHttpStatus(statusCode, getHttpStatusText(attributes, statusCode)) + : ''; + return [request, status].filter(Boolean).join(' · ') || undefined; +} + +function getDetailRows( + attributes: Attributes, + spanKind: string | undefined, + method: string | undefined, + status: RpcStatus | undefined, +): RequestDetailRow[] { + const errorType = firstSetString(attributes, ['error.type']); + const route = getHttpRoute(attributes); + return [ + ['Error message', firstSetString(attributes, ['grpc.error_message'])], + // Instrumentations set error.type to the status, which the badge shows. + [ + 'Error type', + errorType === status?.name || errorType === status?.code?.toString() + ? undefined + : errorType, + ], + ['Server', getRpcServerAddress(attributes, spanKind)], + ['Remote address', getNetworkPeerAddress(attributes)], + ['Protocol', getHttpProtocol(attributes)], + ['HTTP', getHttpSummary(attributes)], + // ASP.NET Core sets the route of a gRPC call to the method path. + ['Route', route === `/${method}` ? undefined : route], + ['User agent', getUserAgent(attributes)], + ]; +} + +/** No copy-as-cURL: curl can't replay a gRPC call. */ +export function RpcCallSubpanel({ + attributes, + spanKind, +}: { + attributes: Attributes; + spanKind?: string; +}) { + const method = getRpcMethod(attributes); + const status = getRpcStatus(attributes); + const hasError = + firstSetString(attributes, ['grpc.error_message', 'error.type']) != null; + + return ( + + + + + + + + + ); +} diff --git a/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx b/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx new file mode 100644 index 0000000000..0ec3180a89 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +jest.mock('../DBRowSidePanel', () => { + const { createContext } = jest.requireActual('react'); + return { + __esModule: true, + RowSidePanelContext: createContext({}), + }; +}); + +const mockDBRowJsonViewer = jest.fn(); +jest.mock('../DBRowJsonViewer', () => ({ + __esModule: true, + DBRowJsonViewer: (props: unknown) => { + mockDBRowJsonViewer(props); + return null; + }, +})); +const mockNetworkPropertySubpanel = jest.fn(); +jest.mock('../NetworkPropertyPanel', () => ({ + __esModule: true, + NetworkPropertySubpanel: (props: unknown) => { + mockNetworkPropertySubpanel(props); + return
; + }, +})); +jest.mock('../linkedSpans', () => ({ + __esModule: true, + useReverseSpanLinks: () => ({ links: [] }), + useLinkedSpanDetails: () => ({ details: new Map() }), +})); + +import { RowOverviewPanel } from '@/components/DBRowOverviewPanel'; + +const TRACE_SOURCE: TSource = { + id: 'trace-src', + name: 'Traces', + kind: SourceKind.Trace, + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, SpanName', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + durationExpression: 'Duration', + durationPrecision: 9, + eventAttributesExpression: 'SpanAttributes', +}; + +function renderWithAttributes( + attributes: Record, + flattenedAttributes: Record = attributes, +) { + mockUseRowData.mockReturnValue({ + data: { + data: [ + { + SpanAttributes: attributes, + __hdx_event_attributes: flattenedAttributes, + __hdx_span_kind: 'Server', + }, + ], + meta: [], + }, + }); + renderWithMantine( + , + ); +} + +describe('RowOverviewPanel request section', () => { + beforeEach(() => { + mockUseRowData.mockReset(); + mockDBRowJsonViewer.mockReset(); + mockNetworkPropertySubpanel.mockReset(); + }); + + it.each([ + ['HTTP Request', { 'url.path': '/users', 'http.request.method': 'GET' }], + // Pre-1.21 server spans often carry no http.url. + ['HTTP Request', { 'http.method': 'GET', 'http.target': '/users' }], + ['gRPC Call', { 'rpc.system': 'grpc', 'rpc.method': 'GetQuote' }], + [ + 'gRPC Call', + { 'grpc.method': '/x.Cart/Empty', 'url.path': '/x.Cart/Empty' }, + ], + ['RPC Call', { 'rpc.service': 'S3', 'rpc.method': 'PutObject' }], + ])('opens the "%s" panel for %o', (title, attributes) => { + renderWithAttributes(attributes); + + expect(screen.getByText(title)).toBeInTheDocument(); + expect(mockNetworkPropertySubpanel).toHaveBeenCalledWith({ + eventAttributes: attributes, + spanKind: 'Server', + }); + }); + + it('detects HTTP spans from a JSON attributes column', () => { + renderWithAttributes( + { url: { path: '/users' }, http: { request: { method: 'GET' } } }, + { 'url.path': '/users', 'http.request.method': 'GET' }, + ); + + expect(screen.getByText('HTTP Request')).toBeInTheDocument(); + }); + + it('hides the panel for a span that is neither HTTP nor RPC', () => { + renderWithAttributes({ 'db.system': 'clickhouse' }); + + expect(screen.queryByTestId('network-subpanel')).not.toBeInTheDocument(); + }); + + it('keeps http.* attributes in the span attributes viewer', () => { + const attributes = { + 'url.full': 'https://example-app.clickhouse.com/users', + 'http.request.method': 'GET', + 'http.request.header.x_forwarded_for': '1.2.3.4', + }; + renderWithAttributes(attributes); + + expect(screen.getByTestId('network-subpanel')).toBeInTheDocument(); + expect(mockDBRowJsonViewer).toHaveBeenCalledWith( + expect.objectContaining({ data: { SpanAttributes: attributes } }), + ); + }); +}); diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index ee7e81ae14..857ca751a5 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -22,6 +22,7 @@ import { } from '@/components/DBTraceWaterfallChart'; import { TimelineChart } from '@/components/TimelineChart'; import { TRACE_WATERFALL_ROW_LIMIT } from '@/components/traceWaterfallLimits'; +import { useColumns } from '@/hooks/useMetadata'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import useRowWhere from '@/hooks/useRowWhere'; @@ -77,6 +78,10 @@ jest.mock('@/components/TimelineChart', () => { jest.mock('@/hooks/useOffsetPaginatedQuery'); jest.mock('@/hooks/useRowWhere'); +jest.mock('@/hooks/useMetadata', () => ({ + ...jest.requireActual('@/hooks/useMetadata'), + useColumns: jest.fn(), +})); jest.mock('../DBRowDataPanel', () => ({ getJSONColumnNames: jest.fn().mockReturnValue([]), })); @@ -116,8 +121,25 @@ jest.mock('@/hooks/useWaterfallSearchState', () => ({ const mockUseOffsetPaginatedQuery = useOffsetPaginatedQuery as jest.Mock; const mockUseRowWhere = useRowWhere as jest.Mock; +const mockUseColumns = useColumns as jest.Mock; const MockTimelineChart = TimelineChart as any; +// Mirrors useColumns: the query is disabled, so never loading, for an empty +// table name unless the caller overrides `enabled`. +const setupColumnsMock = (state: { + data?: Array<{ name: string; type: string }>; + isLoading?: boolean; +}) => + mockUseColumns.mockImplementation( + ({ tableName }: { tableName: string }, opts?: { enabled?: boolean }) => + (opts?.enabled ?? !!tableName) + ? { data: state.data, isLoading: state.isLoading ?? false } + : { data: undefined, isLoading: false }, + ); + +const selectsSeverityNumber = (query: { select: Array<{ alias?: string }> }) => + query.select.some(s => s.alias === 'SeverityNumber'); + describe('DBTraceWaterfallChartContainer', () => { // Common test data const mockTraceTableSource: TTraceSource = { @@ -202,6 +224,7 @@ describe('DBTraceWaterfallChartContainer', () => { mockUseRowWhere.mockReturnValue(() => ({ where: 'row-id', aliasWith: [] })); MockTimelineChart.latestProps = {}; mockUseWaterfallSearchState.mockReturnValue(makeWaterfallSearchState()); + setupColumnsMock({ data: [] }); }); // Helper functions @@ -240,7 +263,7 @@ describe('DBTraceWaterfallChartContainer', () => { // the unfiltered fallback still returns data. const setupQueryMocks = (options: { traceData?: typeof mockTraceData | typeof emptyData; - logData?: typeof mockLogData | typeof emptyData; + logData?: { data: object[]; meta: object[] }; traceError?: Error; logError?: Error; traceFilterError?: Error; @@ -702,7 +725,7 @@ describe('DBTraceWaterfallChartContainer', () => { HyperDXEventType: 'span' as const, type: 'trace', SpanAttributes: { - 'http.url': 'https://api.example.com/users', + 'http.url': 'https://example-api.clickhouse.com/users', 'http.method': 'GET', 'http.status_code': 200, }, @@ -720,10 +743,139 @@ describe('DBTraceWaterfallChartContainer', () => { expect(MockTimelineChart.latestProps.rows[0]).toBeTruthy(); expect( - screen.getByText('http span https://api.example.com/users'), + screen.getByText('http span https://example-api.clickhouse.com/users'), ).toBeInTheDocument(); }); + const apiUrl = 'https://example-api.clickhouse.com/users?id=1'; + it.each([ + [{ 'url.full': apiUrl }, apiUrl], + [ + { + 'url.scheme': 'https', + 'server.address': 'example-api.clickhouse.com', + 'url.path': '/users', + 'url.query': 'id=1', + }, + apiUrl, + ], + [{ 'http.url': apiUrl }, apiUrl], + // An RPC span is named after its method, which the URL repeats. + [{ 'grpc.method': '/demo.QuoteService/GetQuote', 'url.full': apiUrl }, ''], + ])('labels a span with %p using URL %p', async (SpanAttributes, url) => { + setupQueryMocks({ + traceData: { + data: [{ ...mockTraceData.data[0], SpanAttributes }], + meta: [{ totalCount: 1 }], + }, + }); + renderComponent(null); + await waitForLoading(); + + const [row] = MockTimelineChart.latestProps.rows; + const displayText = url ? `test span ${url}` : 'test span'; + expect(row.events[0].tooltip).toContain(`${displayText} took`); + const title = url ? `test-service ${displayText}` : 'test-service'; + expect(renderWithMantine(row.label).getByTitle(title)).toBeInTheDocument(); + }); + + const renderWithLogs = async (logs: Record[]) => { + setupQueryMocks({ + traceData: mockTraceData, + logData: { + data: logs.map(log => ({ + ...mockLogData.data[0], + SeverityText: undefined, + ...log, + })), + meta: [{ totalCount: logs.length }], + }, + }); + renderComponent(); + await waitForLoading(); + }; + + it.each([ + [{ SeverityNumber: 17, SeverityText: 'info' }, ['Error']], + [{ SeverityNumber: 9, SeverityText: 'error' }, []], + [{ SeverityNumber: 0, SeverityText: 'ERROR' }, ['Error']], + [{ SeverityText: 'Warning' }, ['Warning']], + [{}, []], + ])('marks a log with %p with icons %p', async (severity, icons) => { + await renderWithLogs([severity]); + + const logRow = MockTimelineChart.latestProps.rows.find( + (row: { type?: string }) => row.type === SourceKind.Log, + ); + const label = renderWithMantine(logRow.label); + expect( + ['Error', 'Warning'].filter(icon => label.queryByLabelText(icon)), + ).toEqual(icons); + }); + + it('counts error logs by SeverityNumber, falling back to SeverityText', async () => { + mockUseRowWhere.mockReturnValue((row: Record) => ({ + where: String(row.Body), + aliasWith: [], + })); + await renderWithLogs([ + { Body: 'by number', SeverityNumber: 17 }, + { Body: 'by text', SeverityText: 'FATAL' }, + { Body: 'number wins', SeverityNumber: 13, SeverityText: 'error' }, + ]); + + expect(screen.getByText('2 errors')).toBeInTheDocument(); + }); + + describe('SeverityNumber column', () => { + const logQueryCalls = () => + mockUseOffsetPaginatedQuery.mock.calls.filter( + ([query]) => query?.from?.tableName === 'log_table', + ); + + it.each([ + ['has it', [{ name: 'SeverityNumber', type: 'UInt8' }], true], + ['lacks it', [{ name: 'SeverityText', type: 'String' }], false], + ['lookup failed', undefined, false], + ])( + 'selects SeverityNumber only when the log table has it (%s)', + async (_name, data, expected) => { + setupColumnsMock({ data }); + setupQueryMocks({ traceData: mockTraceData, logData: mockLogData }); + renderComponent(); + await waitForLoading(); + + const enabled = logQueryCalls().filter(([, opts]) => opts?.enabled); + expect(enabled.length).toBeGreaterThan(0); + for (const [query] of enabled) { + expect(selectsSeverityNumber(query)).toBe(expected); + } + expect(MockTimelineChart.latestProps.rows.length).toBe(2); + }, + ); + + it('holds the log query until the columns load', () => { + setupColumnsMock({ isLoading: true }); + setupQueryMocks({ traceData: mockTraceData, logData: mockLogData }); + renderComponent(); + + expect(screen.getByText('Loading Traces...')).toBeInTheDocument(); + const enabled = logQueryCalls().map(([, opts]) => opts?.enabled); + expect(enabled).toContain(false); + expect(enabled).not.toContain(true); + }); + + it('renders a trace without a log source', async () => { + // Only a lookup disabled by its empty table name skips this loading state. + setupColumnsMock({ isLoading: true }); + setupQueryMocks({ traceData: mockTraceData }); + renderComponent(null); + await waitForLoading(); + + expect(MockTimelineChart.latestProps.rows.length).toBe(1); + }); + }); + it('renders Spans and Logs chips when log source is present', async () => { setupQueryMocks({ traceData: mockTraceData, logData: mockLogData }); renderComponent(); @@ -962,15 +1114,30 @@ describe('useEventsAroundFocus', () => { mockUseRowWhere.mockReturnValue(() => ({ where: 'row-id', aliasWith: [] })); }); + const mockLogSource: TLogSource = { + id: 'test-log-source-id', + kind: SourceKind.Log, + name: 'log-source', + from: { databaseName: 'test_db', tableName: 'log_table' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp', + implicitColumnExpression: 'Body', + connection: 'conn2', + }; + const testEventsAroundFocus = (options: { beforeData?: any; afterData?: any; enabled?: boolean; + tableSource?: TTraceSource | TLogSource; + selectSeverityNumber?: boolean; }) => { const { beforeData = { data: [], meta: [] }, afterData = { data: [], meta: [] }, enabled = true, + tableSource = mockTableSource, + selectSeverityNumber, } = options; mockUseOffsetPaginatedQuery.mockReset(); @@ -988,17 +1155,52 @@ describe('useEventsAroundFocus', () => { const { result } = renderHook(() => useEventsAroundFocus({ - tableSource: mockTableSource, + tableSource, focusDate: mockFocusDate, dateRange: mockDateRange, traceId: mockTraceId, enabled, + selectSeverityNumber, }), ); return result.current; }; + it.each([ + ['log', true, true], + ['log', false, false], + ['trace', true, false], + ])( + 'from a %s source with selectSeverityNumber=%p, selects SeverityNumber: %p', + (kind, selectSeverityNumber, expected) => { + testEventsAroundFocus({ + tableSource: kind === 'log' ? mockLogSource : mockTableSource, + selectSeverityNumber, + }); + + const [query] = mockUseOffsetPaginatedQuery.mock.calls[0]; + expect(selectsSeverityNumber(query)).toBe(expected); + }, + ); + + it('leaves SeverityNumber out of row ids but keeps it on the row', () => { + const getRowWhere = jest.fn(() => ({ where: 'row-id', aliasWith: [] })); + mockUseRowWhere.mockReturnValue(getRowWhere); + + const result = testEventsAroundFocus({ + tableSource: mockLogSource, + selectSeverityNumber: true, + beforeData: { + data: [{ Body: 'log line', SeverityNumber: 17 }], + meta: [{ name: 'Body', type: 'String' }], + }, + }); + + expect(getRowWhere).toHaveBeenCalledWith({ Body: 'log line' }); + expect(result.rows[0]).toMatchObject({ id: 'row-id', SeverityNumber: 17 }); + }); + it('fetches events before and after focus date', () => { const mockBeforeData = { data: [{ Body: 'before focus' }], diff --git a/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx b/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx new file mode 100644 index 0000000000..fc76f5fbd7 --- /dev/null +++ b/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { notifications } from '@mantine/notifications'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; + +import { NetworkPropertySubpanel } from '@/components/NetworkPropertyPanel'; +import { copyTextToClipboard } from '@/utils/clipboard'; + +jest.mock('@/utils/clipboard', () => ({ + __esModule: true, + CLIPBOARD_ERROR_MESSAGE: 'clipboard error', + copyTextToClipboard: jest.fn(), +})); + +jest.mock('@mantine/notifications', () => ({ + ...jest.requireActual('@mantine/notifications'), + notifications: { show: jest.fn() }, +})); + +// click-ui encodes the badge state only in hashed CSS module class names. +jest.mock('@clickhouse/click-ui', () => ({ + ...jest.requireActual('@clickhouse/click-ui'), + Badge: ({ text, state }: { text: React.ReactNode; state?: string }) => ( + + {text} + + ), +})); + +const mockCopy = jest.mocked(copyTextToClipboard); +const mockShowNotification = jest.mocked(notifications.show); + +const CLIENT_SPAN = { + 'url.scheme': 'https', + 'server.address': 'example-api.clickhouse.com', + 'url.path': '/users', + 'url.query': 'id=1', + 'http.request.method': 'POST', + 'http.response.status_code': 503, + 'network.peer.address': '10.0.0.1', + 'http.request.header.x_forwarded_for': '1.2.3.4', + 'http.request.header.traceparent': '["00-0d98-cef1-01"]', + 'http.request.body.content': '{"name":"a"}', + 'http.response.header.content-type': '["application/json"]', + 'http.response.body.content': '{"error":"down"}', +}; + +const renderPanel = (eventAttributes: Record) => + renderWithMantine( + , + ); + +const getBadge = (text: string) => + screen.getByText(text, { selector: '[data-testid="badge"]' }); + +function getDetail(label: string) { + const row = screen.getByText(label).closest('tr'); + if (row == null) throw new Error(`No row for ${label}`); + return within(row).getAllByRole('cell')[1]; +} + +describe('NetworkPropertySubpanel', () => { + beforeEach(() => { + mockCopy.mockReset().mockResolvedValue(true); + mockShowNotification.mockReset(); + }); + + it('renders the method, URL and status on one request line', () => { + renderPanel(CLIENT_SPAN); + + expect(getBadge('POST')).toHaveAttribute('data-state', 'neutral'); + expect( + screen.getByText('https://example-api.clickhouse.com/users?id=1'), + ).toBeInTheDocument(); + expect(getBadge('503 Service Unavailable')).toBeInTheDocument(); + }); + + it.each([ + ['200', '200 OK', 'success'], + ['299', '299', 'success'], + ['304', '304 Not Modified', 'info'], + ['404', '404 Not Found', 'warning'], + ['503', '503 Service Unavailable', 'danger'], + // The HyperDX browser SDK reports 0 for a request that got no response. + ['0', '0 No response', 'danger'], + ])('shows status %s as "%s" (%s)', (code, text, state) => { + renderPanel({ 'http.method': 'GET', 'http.status_code': code }); + expect(getBadge(text)).toHaveAttribute('data-state', state); + }); + + it('renders the details of a semconv server span', () => { + renderPanel({ + 'client.address': '::1', + 'http.request.method': 'GET', + 'http.response.status_code': '200', + 'http.route': '/api/hello', + 'network.peer.address': '::1', + 'network.peer.port': '49347', + 'network.protocol.version': '1.1', + 'server.address': 'localhost', + 'server.port': '3001', + 'url.path': '/api/hello', + 'url.scheme': 'http', + 'user_agent.original': 'Mozilla/5.0 Chrome/155.0.0.0', + }); + + expect(screen.getByText('http://localhost:3001/api/hello')).toBeVisible(); + expect(getDetail('Route')).toHaveTextContent('/api/hello'); + expect(getDetail('Protocol')).toHaveTextContent('HTTP/1.1'); + expect(getDetail('Remote address')).toHaveTextContent('[::1]:49347'); + expect(getDetail('User agent')).toHaveTextContent( + 'Mozilla/5.0 Chrome/155.0.0.0', + ); + // The client is the socket peer, already shown as the remote address. + expect(screen.queryByText('Client address')).not.toBeInTheDocument(); + }); + + it('shows sizes, resends and a synthetic user agent', () => { + renderPanel({ + ...CLIENT_SPAN, + 'http.request.body.size': '1536', + 'http.request.size': '1900', + 'http.response.body.size': '87', + 'http.request.resend_count': '2', + 'user_agent.original': 'Googlebot/2.1', + 'user_agent.synthetic.type': 'bot', + }); + + expect(getDetail('Request size')).toHaveTextContent( + '1.5 KB body · 1.9 KB total', + ); + expect(getDetail('Response size')).toHaveTextContent('87 B body'); + expect(getDetail('Resends')).toHaveTextContent('2'); + expect(getBadge('bot')).toHaveAttribute('data-state', 'neutral'); + }); + + it('shows the error type unless it repeats the status code', () => { + const { unmount } = renderPanel({ ...CLIENT_SPAN, 'error.type': '503' }); + expect(screen.queryByText('Error type')).not.toBeInTheDocument(); + unmount(); + + renderPanel({ ...CLIENT_SPAN, 'error.type': '_OTHER' }); + expect(getDetail('Error type')).toHaveTextContent('_OTHER'); + }); + + it('lists headers by dashed name with the raw key on hover', () => { + renderPanel(CLIENT_SPAN); + + expect(screen.getByText('Request body')).toBeInTheDocument(); + expect(screen.getByText('Response headers (1)')).toBeInTheDocument(); + expect(screen.getByText('Response body')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Request headers (2)')); + + expect(screen.getByText('X-Forwarded-For')).toHaveAttribute( + 'title', + 'http.request.header.x_forwarded_for', + ); + expect(screen.getByText('1.2.3.4')).toBeInTheDocument(); + }); + + it('flags a body cut short of its reported size', () => { + renderPanel({ + ...CLIENT_SPAN, + 'http.response.body.content': 'a'.repeat(8192), + 'http.response.body.size': '23817', + }); + + expect( + screen.getByText('Response body (truncated: 8.0 KB of 23.3 KB)'), + ).toBeInTheDocument(); + }); + + it('renders a deprecated client span', () => { + renderPanel({ + 'http.url': 'https://old-example.clickhouse.com/a', + 'http.method': 'GET', + 'http.status_code': '404', + 'http.status_text': 'Page Gone', + 'net.peer.ip': '10.0.0.2', + 'net.peer.port': '8080', + 'http.flavor': '1.1', + 'http.user_agent': 'python-requests/2.32.3', + }); + + expect( + screen.getByText('https://old-example.clickhouse.com/a'), + ).toBeInTheDocument(); + expect(getBadge('404 Page Gone')).toHaveAttribute('data-state', 'warning'); + expect(getDetail('Remote address')).toHaveTextContent('10.0.0.2:8080'); + expect(getDetail('Protocol')).toHaveTextContent('HTTP/1.1'); + expect(getDetail('User agent')).toHaveTextContent('python-requests/2.32.3'); + }); + + it('copies a cURL command without the trace header', async () => { + renderPanel(CLIENT_SPAN); + fireEvent.click(screen.getByRole('button', { name: 'Copy as cURL' })); + + await waitFor(() => + expect(mockShowNotification).toHaveBeenCalledWith({ + color: 'green', + message: 'cURL command copied to clipboard', + }), + ); + const curl = mockCopy.mock.calls[0][0]; + expect(curl).toContain( + 'curl https://example-api.clickhouse.com/users?id=1', + ); + expect(curl).toContain('-X POST'); + expect(curl).toContain('-H "X-Forwarded-For: 1.2.3.4"'); + expect(curl).not.toContain('traceparent'); + expect(curl).toContain(`--data-raw '{"name":"a"}'`); + }); + + it('reports a clipboard failure', async () => { + mockCopy.mockResolvedValue(false); + renderPanel(CLIENT_SPAN); + fireEvent.click(screen.getByRole('button', { name: 'Copy as cURL' })); + + await waitFor(() => + expect(mockShowNotification).toHaveBeenCalledWith({ + color: 'red', + message: 'clipboard error', + }), + ); + }); + + it('hides the cURL action for a URL without a host', () => { + renderPanel({ 'http.method': 'GET', 'http.target': '/users' }); + + expect(screen.getByText('/users')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Copy as cURL' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/__tests__/RpcCallPanel.test.tsx b/packages/app/src/components/__tests__/RpcCallPanel.test.tsx new file mode 100644 index 0000000000..cfeb3e3000 --- /dev/null +++ b/packages/app/src/components/__tests__/RpcCallPanel.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { fireEvent, screen, within } from '@testing-library/react'; + +import { NetworkPropertySubpanel } from '@/components/NetworkPropertyPanel'; + +// click-ui encodes the badge state only in hashed CSS module class names. +jest.mock('@clickhouse/click-ui', () => ({ + ...jest.requireActual('@clickhouse/click-ui'), + Badge: ({ text, state }: { text: React.ReactNode; state?: string }) => ( + + {text} + + ), +})); + +const GRPC_CALL = { + 'rpc.method': 'GetQuote', + 'rpc.service': 'demo.QuoteService', + 'rpc.system': 'grpc', + 'server.address': '127.0.0.1', + 'server.port': '3002', +}; + +const renderPanel = (eventAttributes: Record) => + renderWithMantine( + , + ); + +const getBadge = (text: string) => + screen.getByText(text, { selector: '[data-testid="badge"]' }); +const badgeTexts = () => + screen.queryAllByTestId('badge').map(badge => badge.textContent); + +function getDetail(label: string) { + const row = screen.getByText(label).closest('tr'); + if (row == null) throw new Error(`No row for ${label}`); + return within(row).getAllByRole('cell')[1]; +} + +const copyButton = () => screen.queryByRole('button', { name: 'Copy as cURL' }); + +describe('RPC call panel', () => { + it('renders a gRPC client error with its metadata', () => { + renderPanel({ + ...GRPC_CALL, + 'grpc.error_message': '5 NOT_FOUND: no quote for missing', + 'rpc.grpc.status_code': '5', + 'rpc.request.metadata.x_request_id': '["cb88b510"]', + 'rpc.response.metadata.x_quote_source': '["pricing-v2"]', + }); + + expect(getBadge('gRPC')).toHaveAttribute('data-state', 'neutral'); + expect(screen.getByText('demo.QuoteService/GetQuote')).toBeInTheDocument(); + expect(getBadge('5 NOT_FOUND')).toHaveAttribute('data-state', 'danger'); + expect(getDetail('Error message')).toHaveTextContent( + '5 NOT_FOUND: no quote for missing', + ); + expect(getDetail('Server')).toHaveTextContent('127.0.0.1:3002'); + expect(screen.queryByText('HTTP')).not.toBeInTheDocument(); + expect(copyButton()).not.toBeInTheDocument(); + + expect(screen.getByText('Response metadata (1)')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Request metadata (1)')); + expect(screen.getByText('x-request-id')).toHaveAttribute( + 'title', + 'rpc.request.metadata.x_request_id', + ); + expect(screen.getByText('cb88b510')).toBeInTheDocument(); + }); + + it('puts the HTTP facts of gRPC over HTTP in a details row', () => { + renderPanel({ + ...GRPC_CALL, + 'error.type': 'NOT_FOUND', + 'http.request.header.content-type': '["application/grpc"]', + 'http.request.method': 'POST', + 'http.response.status_code': '200', + 'http.route': '/demo.QuoteService/GetQuote', + 'network.protocol.version': '2', + 'rpc.grpc.status_code': '5', + 'url.path': '/demo.QuoteService/GetQuote', + 'url.scheme': 'http', + }); + + expect(badgeTexts()).toEqual(['gRPC', '5 NOT_FOUND']); + expect(getDetail('HTTP')).toHaveTextContent( + 'POST http://127.0.0.1:3002/demo.QuoteService/GetQuote · 200 OK', + ); + expect(getDetail('Protocol')).toHaveTextContent('HTTP/2'); + expect(screen.queryByText('Route')).not.toBeInTheDocument(); + expect(screen.queryByText('Error type')).not.toBeInTheDocument(); + expect(screen.getByText('Request headers (1)')).toBeInTheDocument(); + expect(copyButton()).not.toBeInTheDocument(); + }); + + it('reads the ASP.NET Core gRPC tags', () => { + renderPanel({ + 'grpc.method': '/demo.QuoteService/GetQuote', + 'grpc.status_code': '0', + 'http.request.method': 'POST', + }); + + expect(screen.getByText('demo.QuoteService/GetQuote')).toBeInTheDocument(); + expect(getBadge('0 OK')).toHaveAttribute('data-state', 'success'); + }); + + it('shows the system and status of another RPC system as emitted', () => { + renderPanel({ + 'rpc.method': 'acme.v1.UserService/GetUser', + 'rpc.status_code': 'not_found', + 'rpc.system': 'connect_rpc', + }); + + expect(badgeTexts()).toEqual(['connect_rpc', 'not_found']); + expect(getBadge('not_found')).toHaveAttribute('data-state', 'danger'); + }); + + it('shows an "Error" badge for an error without a status code', () => { + renderPanel({ ...GRPC_CALL, 'grpc.error_message': 'card expired' }); + + expect(getBadge('Error')).toHaveAttribute('data-state', 'danger'); + expect(getDetail('Error message')).toHaveTextContent('card expired'); + }); +}); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 55de463f50..b8e0e91ea3 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -353,6 +353,27 @@ export const getLogLevelClass = (lvl: string | undefined) => { : undefined; }; +/** + * Error or warn level of a log row, or undefined when it is neither. + * SeverityNumber is normalized by the SDK, so it wins when set; 0 means + * unspecified, which falls back to matching SeverityText. + * https://github.com/open-telemetry/opentelemetry-specification/blob/v1.59.0/specification/logs/data-model.md#field-severitynumber + */ +export function getLogSeverityLevel({ + severityNumber, + severityText, +}: { + severityNumber?: unknown; + severityText?: string; +}): 'error' | 'warn' | undefined { + const number = Number(severityNumber); + if (number > 0) { + return number >= 17 ? 'error' : number >= 13 ? 'warn' : undefined; + } + const level = getLogLevelClass(severityText); + return level === 'error' || level === 'warn' ? level : undefined; +} + /** * Chart Categorical Palette - ten distinguishable hues based on * Observable 10 (https://observablehq.com/@d3/color-schemes), with diff --git a/packages/app/src/utils/__tests__/curlGenerator.test.ts b/packages/app/src/utils/__tests__/curlGenerator.test.ts new file mode 100644 index 0000000000..d6680817d5 --- /dev/null +++ b/packages/app/src/utils/__tests__/curlGenerator.test.ts @@ -0,0 +1,38 @@ +import { CurlGenerator } from '@/utils/curlGenerator'; + +const url = 'https://example.com'; + +describe('CurlGenerator', () => { + it.each([ + ['post', '-X POST'], + ['PROPFIND', '-X PROPFIND'], + ['GET;rm', "-X 'GET;RM'"], + ])('emits the method %s as %s', (method, expected) => { + expect(CurlGenerator({ url, method })).toContain(expected); + }); + + it('omits -X without a method', () => { + expect(CurlGenerator({ url })).not.toContain('-X'); + }); + + it('emits one -H per header, including repeated names', () => { + const curl = CurlGenerator({ + url, + headers: [ + ['set-cookie', 'a=1'], + ['set-cookie', 'b="2"'], + ], + }); + expect(curl).toContain('-H "set-cookie: a=1"'); + expect(curl).toContain('-H "set-cookie: b=\\"2\\""'); + }); + + it('sends string bodies raw and object bodies as JSON', () => { + expect(CurlGenerator({ url, body: 'a=1&b=2' })).toContain( + "--data-raw 'a=1&b=2'", + ); + expect(CurlGenerator({ url, body: { a: 1 } })).toContain( + '-d "{\\"a\\":1}"', + ); + }); +}); diff --git a/packages/app/src/utils/__tests__/httpHeaders.test.ts b/packages/app/src/utils/__tests__/httpHeaders.test.ts new file mode 100644 index 0000000000..d88f080f5e --- /dev/null +++ b/packages/app/src/utils/__tests__/httpHeaders.test.ts @@ -0,0 +1,103 @@ +import { + firstHttpHeaderValue, + getReplayableHeaders, + HTTP_REQUEST_HEADER_PREFIX, + parseHttpHeaders, + toHeaderDisplayCase, +} from '@/utils/httpHeaders'; + +const parseRequestHeaders = (attributes: Record) => + parseHttpHeaders(attributes, HTTP_REQUEST_HEADER_PREFIX); + +describe('parseHttpHeaders', () => { + it('reads one prefix and shows Python underscore names with dashes', () => { + expect( + parseRequestHeaders({ + 'http.request.header.x_forwarded_for': '1.2.3.4', + 'http.response.header.content-type': 'text/html', + 'http.url': 'https://clickhouse.com', + }), + ).toEqual([ + { + name: 'x-forwarded-for', + value: '1.2.3.4', + attributeKey: 'http.request.header.x_forwarded_for', + }, + ]); + }); + + it.each([['a=1', 'b=2'], '["a=1","b=2"]'])( + 'expands the array value %p into one entry each', + value => { + expect( + parseRequestHeaders({ 'http.request.header.cookie': value }).map( + header => header.value, + ), + ).toEqual(['a=1', 'b=2']); + }, + ); + + it('keeps values that are not JSON arrays verbatim', () => { + expect( + parseRequestHeaders({ + 'http.request.header.content-length': '0123', + 'http.request.header.if-none-match': '"abc"', + 'http.request.header.x-broken': '[not json', + 'http.request.header.x-count': 3, + 'http.request.header.x-object': { a: 1 }, + }).map(header => header.value), + ).toEqual(['0123', '"abc"', '[not json', '3', '{"a":1}']); + }); +}); + +describe('firstHttpHeaderValue', () => { + const userAgent = (attributes: Record) => + firstHttpHeaderValue(attributes, HTTP_REQUEST_HEADER_PREFIX, 'user-agent'); + + it('matches the name case-insensitively and takes the first value', () => { + expect(userAgent({ 'http.request.header.User_Agent': '["a","b"]' })).toBe( + 'a', + ); + }); + + it('skips empty values and other prefixes', () => { + expect( + userAgent({ + 'http.request.header.user-agent': '', + 'http.response.header.user-agent': 'a', + }), + ).toBeUndefined(); + }); +}); + +describe('getReplayableHeaders', () => { + it('drops trace context, Host, Content-Length and connection headers', () => { + const headers = parseRequestHeaders({ + 'http.request.header.traceparent': '["00-0d98-cef1-01"]', + 'http.request.header.Host': 'localhost:3001', + 'http.request.header.content-length': '["90"]', + 'http.request.header.content-type': '["application/json"]', + 'http.request.header.baggage': 'tenant=acme', + }); + expect(getReplayableHeaders(headers).map(header => header.name)).toEqual([ + 'content-type', + 'baggage', + ]); + }); +}); + +describe('toHeaderDisplayCase', () => { + it('capitalizes each dash-separated word and keeps the attribute key', () => { + expect( + toHeaderDisplayCase({ + name: 'x-forwarded-for', + value: '1.2.3.4', + attributeKey: 'http.request.header.x_forwarded_for', + }), + ).toEqual({ + name: 'X-Forwarded-For', + value: '1.2.3.4', + attributeKey: 'http.request.header.x_forwarded_for', + }); + }); +}); diff --git a/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts b/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts new file mode 100644 index 0000000000..54dfa5e1b3 --- /dev/null +++ b/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts @@ -0,0 +1,158 @@ +import { + getHttpBody, + getHttpBodyTruncation, + getHttpMessageSize, + getHttpMethod, + getHttpProtocol, + getHttpResendCount, + getHttpRoute, + getHttpStatusCode, + getHttpStatusText, + getUserAgent, + isHttpSpan, +} from '@/utils/httpSpanAttributes'; + +describe('isHttpSpan', () => { + it.each([ + [{ 'http.response.status_code': 0 }, true], + [{ 'http.target': '/a?b=1' }, true], + [{ 'url.full': '', 'http.request.method': null }, false], + [{ 'db.system': 'clickhouse' }, false], + ])('detects %o: %p', (attributes, expected) => { + expect(isHttpSpan(attributes)).toBe(expected); + }); +}); + +describe('getHttpMethod', () => { + it.each([ + [{ 'http.request.method': 'POST', 'http.method': 'GET' }, 'POST'], + [{ 'http.request.method': '', 'http.method': 'GET' }, 'GET'], + [ + { + 'http.request.method': '_OTHER', + 'http.request.method_original': 'PROPFIND', + }, + 'PROPFIND', + ], + [{ 'http.request.method': '_OTHER' }, '_OTHER'], + [{}, undefined], + ])('reads %o as %p', (attributes, expected) => { + expect(getHttpMethod(attributes)).toBe(expected); + }); +}); + +describe('getHttpStatusCode', () => { + it.each([ + [{ 'http.response.status_code': 404, 'http.status_code': 200 }, 404], + [{ 'http.response.status_code': '', 'http.status_code': '500' }, 500], + [{ 'http.status_code': '0' }, 0], + [{ 'http.response.status_code': 'abc' }, undefined], + ])('reads %o as %p', (attributes, expected) => { + expect(getHttpStatusCode(attributes)).toBe(expected); + }); + + it.each([ + [{ 'http.status_text': 'Nope' }, 404, 'Nope'], + [{}, 404, 'Not Found'], + [{}, 418, undefined], + [{}, undefined, undefined], + ])('reads the status text of %o and %p', (attributes, code, expected) => { + expect(getHttpStatusText(attributes, code)).toBe(expected); + }); +}); + +describe('getHttpRoute', () => { + it('prefers http.route over url.template', () => { + expect(getHttpRoute({ 'http.route': '/u/:id', 'url.template': 'x' })).toBe( + '/u/:id', + ); + expect(getHttpRoute({ 'url.template': '/u/{id}' })).toBe('/u/{id}'); + }); +}); + +describe('getHttpProtocol', () => { + it.each([ + [{ 'network.protocol.version': '2', 'http.flavor': '1.0' }, 'HTTP/2'], + [{ 'net.protocol.name': 'spdy', 'net.protocol.version': '3' }, 'SPDY/3'], + [{ 'http.flavor': '3.0' }, 'HTTP/3'], + [{ 'http.flavor': 'QUIC' }, 'QUIC'], + [{ 'network.protocol.name': 'http' }, undefined], + ])('formats %o as %p', (attributes, expected) => { + expect(getHttpProtocol(attributes)).toBe(expected); + }); +}); + +describe('getUserAgent', () => { + it.each([ + [{ 'user_agent.original': 'a', 'rpc.user_agent': 'b' }, 'a'], + [{ 'rpc.user_agent': 'b', 'http.user_agent': 'c' }, 'b'], + [{ 'http.user_agent': 'c', 'http.request.header.user-agent': 'd' }, 'c'], + [{ 'http.request.header.user_agent': '["d"]' }, 'd'], + ])('reads %o as %p', (attributes, expected) => { + expect(getUserAgent(attributes)).toBe(expected); + }); +}); + +describe('getHttpMessageSize', () => { + it('reads the body size chain in order', () => { + const chain = [ + ['http.response.body.size', '10'], + ['http.response_content_length', '20'], + ['http.response.header.content-length', '30'], + ['http.response_content_length_uncompressed', '40'], + ]; + const attributes = Object.fromEntries(chain); + for (const [key, size] of chain) { + expect(getHttpMessageSize(attributes, 'response')).toBe(`${size} B body`); + delete attributes[key]; + } + expect(getHttpMessageSize(attributes, 'response')).toBeUndefined(); + }); + + it('shows the body and total sizes', () => { + expect( + getHttpMessageSize( + { 'http.request.body.size': '1536', 'http.request.size': '1946' }, + 'request', + ), + ).toBe('1.5 KB body · 1.9 KB total'); + expect(getHttpMessageSize({ 'http.request.size': 0 }, 'request')).toBe( + '0 B total', + ); + }); +}); + +describe('getHttpResendCount', () => { + it.each([ + [{ 'http.request.resend_count': '2', 'http.resend_count': '3' }, 2], + [{ 'http.resend_count': '3', 'http.retry_count': '4' }, 3], + [{ 'http.retry_count': 4 }, 4], + ])('reads %o as %p', (attributes, expected) => { + expect(getHttpResendCount(attributes)).toBe(expected); + }); +}); + +describe('getHttpBody', () => { + it.each([ + [{ 'http.request.body.content': 'new', 'http.request.body': 'old' }, 'new'], + [{ 'http.request.body': 'old' }, 'old'], + [{ 'http.request.body.content': '', 'http.request.body': 'old' }, ''], + [{ 'http.response.body': 'old' }, undefined], + ])('reads the request body of %o as %p', (attributes, expected) => { + expect(getHttpBody(attributes, 'request')).toBe(expected); + }); + + it.each([ + ['a cut body', '23817', 'a'.repeat(8192), '8.0 KB of 23.3 KB'], + ['UTF-8 bytes, not characters', '4', 'éé', undefined], + ['a non-string body', '30', { a: 1 }, undefined], + ['no reported size', undefined, 'abc', undefined], + ])('reports truncation for %s', (_case, size, body, expected) => { + expect( + getHttpBodyTruncation( + { 'http.response.body.size': size, 'http.response.body.content': body }, + 'response', + ), + ).toBe(expected); + }); +}); diff --git a/packages/app/src/utils/__tests__/httpUrl.test.ts b/packages/app/src/utils/__tests__/httpUrl.test.ts new file mode 100644 index 0000000000..5c9c1eacd0 --- /dev/null +++ b/packages/app/src/utils/__tests__/httpUrl.test.ts @@ -0,0 +1,120 @@ +import { getHttpUrl } from '@/utils/httpUrl'; + +describe('getHttpUrl', () => { + const stable = { + 'url.scheme': 'https', + 'server.address': 'new-example.clickhouse.com', + 'url.path': '/a', + }; + + it.each([ + [ + 'url.full over everything else', + { ...stable, 'url.full': 'https://full-example.clickhouse.com/a' }, + 'https://full-example.clickhouse.com/a', + ], + [ + 'an absolute stable rebuild over http.url', + { + ...stable, + 'url.query': '?b=1', + 'http.url': 'https://old-example.clickhouse.com', + }, + 'https://new-example.clickhouse.com/a?b=1', + ], + [ + 'http.url over a relative stable rebuild', + { 'url.scheme': 'http', 'url.path': '/a', 'http.url': 'http://e.com/a' }, + 'http://e.com/a', + ], + [ + 'http.url when the other values are empty', + { 'url.full': '', 'url.path': '', 'http.url': 'http://e.com/a' }, + 'http://e.com/a', + ], + [ + 'the path and query without a host', + { 'url.path': '/users', 'url.query': 'id=1' }, + '/users?id=1', + ], + ['nothing without a URL attribute', { 'http.method': 'GET' }, undefined], + ])('returns %s', (_case, attributes, expected) => { + expect(getHttpUrl(attributes)).toBe(expected); + }); + + it.each([ + [ + 'HTTPS', + 'api-example.clickhouse.com', + 443, + 'HTTPS://api-example.clickhouse.com/a', + ], + ['http', 'localhost', 8080, 'http://localhost:8080/a'], + ['http', '::1', 8080, 'http://[::1]:8080/a'], + ['http', '[::1]', undefined, 'http://[::1]/a'], + ['http', 'localhost:5000', 5000, 'http://localhost:5000/a'], + ['http', '[::1]:8000', 8000, 'http://[::1]:8000/a'], + ])('joins %s, %s and port %p', (scheme, address, port, expected) => { + expect( + getHttpUrl({ + 'url.scheme': scheme, + 'server.address': address, + 'server.port': port, + 'url.path': '/a', + }), + ).toBe(expected); + }); + + describe('pre-1.21 spans', () => { + it.each([ + [{ 'http.host': 'h.io:81', 'net.host.name': 'x' }, 'http://h.io:81/a'], + [{ 'net.host.name': 'h.io', 'net.host.port': 81 }, 'http://h.io:81/a'], + [{ 'http.server_name': 'h.io', 'net.host.port': '80' }, 'http://h.io/a'], + [{ 'net.peer.name': 'h.io', 'net.peer.port': 81 }, 'http://h.io:81/a'], + [{ 'http.host': 'h.io', 'net.peer.name': 'client' }, 'http://h.io/a'], + [{ 'server.address': 'h.io', 'http.host': 'old.io' }, 'http://h.io/a'], + ])('rebuilds from http.target and %o', (host, expected) => { + expect( + getHttpUrl({ 'http.scheme': 'http', 'http.target': '/a', ...host }), + ).toBe(expected); + }); + + it.each([ + [ + 'http.url over the parts of an OTel JS client span without a scheme', + { + 'http.url': 'https://api-example.clickhouse.com/v1/users?x=1', + 'http.target': '/v1/users?x=1', + 'http.host': 'api-example.clickhouse.com:443', + }, + 'https://api-example.clickhouse.com/v1/users?x=1', + ], + [ + 'http.url over an ASGI http.target without the query', + { + 'http.scheme': 'http', + 'http.host': 'localhost:8000', + 'http.target': '/items', + 'http.url': 'http://localhost:8000/items?q=shoes', + }, + 'http://localhost:8000/items?q=shoes', + ], + [ + 'url.path and url.query over http.target', + { 'url.path': '/new', 'url.query': 'a=1', 'http.target': '/old?b=2' }, + '/new?a=1', + ], + [ + 'an absolute-form http.target as is', + { + 'http.host': 'proxy:3128', + 'http.target': 'http://up-example.clickhouse.com/a', + }, + 'http://up-example.clickhouse.com/a', + ], + ['http.target alone without a host', { 'http.target': '/a' }, '/a'], + ])('returns %s', (_case, attributes, expected) => { + expect(getHttpUrl(attributes)).toBe(expected); + }); + }); +}); diff --git a/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts b/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts new file mode 100644 index 0000000000..2234e2df72 --- /dev/null +++ b/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts @@ -0,0 +1,120 @@ +import { + getRequestPanelTitle, + getRpcMetadata, + getRpcMethod, + getRpcServerAddress, + getRpcStatus, + getRpcSystemLabel, +} from '@/utils/rpcSpanAttributes'; + +describe('getRequestPanelTitle', () => { + it.each([ + [{ 'rpc.system': 'grpc', 'http.request.method': 'POST' }, 'gRPC Call'], + [{ 'rpc.grpc.status_code': 0 }, 'gRPC Call'], + [{ 'rpc.system.name': 'connect_rpc' }, 'RPC Call'], + [{ 'rpc.service': 'pkg.Service' }, 'RPC Call'], + [{ 'grpc.method': '', 'http.request.method': 'GET' }, 'HTTP Request'], + [{ 'db.system': 'clickhouse' }, undefined], + ])('titles %o as %p', (attributes, expected) => { + expect(getRequestPanelTitle(attributes)).toBe(expected); + }); + + it.each([ + [{ 'grpc.method': '/a.B/C' }, 'gRPC'], + [{ 'rpc.system': 'aws-api' }, 'aws-api'], + [{ 'rpc.method': 'a.B/C' }, 'RPC'], + ])('labels the system of %o as %p', (attributes, expected) => { + expect(getRpcSystemLabel(attributes)).toBe(expected); + }); +}); + +describe('getRpcMethod', () => { + it.each([ + [{ 'rpc.method': 'a.B/C', 'grpc.method': '/x.Y/Z' }, 'a.B/C'], + [{ 'rpc.service': 'a.B', 'rpc.method': 'C' }, 'a.B/C'], + [{ 'rpc.method': '_OTHER', 'rpc.method_original': 'refund' }, 'refund'], + [{ 'rpc.method': '_OTHER' }, '_OTHER'], + [{ 'grpc.method': '/a.B/C' }, 'a.B/C'], + [{ 'rpc.system': 'grpc', 'url.path': '/a.B/C' }, 'a.B/C'], + [{ 'rpc.system': 'connect_rpc', 'url.path': '/a.B/C' }, undefined], + [{ 'grpc.method': 'other', 'url.path': '/a.B/C' }, 'a.B/C'], + [{ 'grpc.method': 'other' }, 'other'], + ])('reads %o as %p', (attributes, expected) => { + expect(getRpcMethod(attributes)).toBe(expected); + }); +}); + +describe('getRpcStatus', () => { + it.each([ + [{ 'rpc.status_code': '4', 'rpc.response.status_code': '0' }], + [{ 'rpc.response.status_code': '4', 'rpc.grpc.status_code': 0 }], + [{ 'rpc.grpc.status_code': 4, 'grpc.status_code': '0' }], + [{ 'grpc.status_code': '4', 'grpc.status': '0' }], + [{ 'grpc.status': '4' }], + ])('reads the newest status in %o', attributes => { + expect(getRpcStatus({ ...attributes, 'rpc.system': 'grpc' })?.label).toBe( + '4 DEADLINE_EXCEEDED', + ); + }); + + it.each([ + ['grpc', 0, '0 OK', true], + ['grpc', '14', '14 UNAVAILABLE', false], + ['grpc', 'not_found', '5 NOT_FOUND', false], + ['grpc', '99', '99', false], + ['jsonrpc', '5', '5', false], + ['onc_rpc', 'ok', 'ok', true], + ])('normalizes a %s status %p to %p', (system, value, label, isOk) => { + expect( + getRpcStatus({ 'rpc.system': system, 'rpc.status_code': value }), + ).toMatchObject({ label, isOk }); + }); + + it('returns undefined without a status', () => { + expect(getRpcStatus({ 'grpc.status_code': '' })).toBeUndefined(); + }); +}); + +describe('getRpcServerAddress', () => { + it.each([ + [{ 'server.address': 'quote', 'server.port': '8080' }, 'quote:8080'], + [{ 'net.peer.name': 'checkout', 'net.peer.port': 8080 }, 'checkout:8080'], + [{ 'server.address': 'fe80::1', 'server.port': 50051 }, '[fe80::1]:50051'], + ])('reads %o as %p', (attributes, expected) => { + expect(getRpcServerAddress(attributes)).toBe(expected); + }); + + it('ignores net.peer.* on a server span, where it names the caller', () => { + const attributes = { 'net.peer.name': 'localhost', 'net.peer.port': 41622 }; + expect(getRpcServerAddress(attributes, 'Server')).toBeUndefined(); + expect(getRpcServerAddress(attributes, 'SPAN_KIND_SERVER')).toBeUndefined(); + }); +}); + +describe('getRpcMetadata', () => { + it('merges request metadata across prefixes and drops repeats', () => { + expect( + getRpcMetadata( + { + 'rpc.request.header.x-request-id': '["abc"]', + 'rpc.request.metadata.x_request_id': '["abc"]', + 'rpc.grpc.request.metadata.x-tenant': 'acme', + 'http.request.header.x-other': 'ignored', + }, + 'request', + ).map(({ name, value }) => `${name}: ${value}`), + ).toEqual(['x-request-id: abc', 'x-tenant: acme']); + }); + + it('includes trailers in the response metadata', () => { + expect( + getRpcMetadata( + { + 'rpc.response.metadata.server': 'envoy', + 'rpc.response.trailer.grpc-status': '["0"]', + }, + 'response', + ).map(({ name, value }) => `${name}: ${value}`), + ).toEqual(['server: envoy', 'grpc-status: 0']); + }); +}); diff --git a/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts b/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts new file mode 100644 index 0000000000..f1f56e390f --- /dev/null +++ b/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts @@ -0,0 +1,83 @@ +import { + formatAddress, + getClientAddress, + getLocalAddress, + getNetworkPeerAddress, +} from '@/utils/spanAttributeReaders'; + +describe('formatAddress', () => { + it.each([ + [{ address: '10.0.0.1' }, '10.0.0.1'], + [{ address: '10.0.0.1', port: '443' }, '10.0.0.1:443'], + [{ address: '2001:db8::1', port: '443' }, '[2001:db8::1]:443'], + [{ address: '[2001:db8::1]', port: '443' }, '[2001:db8::1]:443'], + [{ address: '/var/run/app.sock', port: '0' }, '/var/run/app.sock'], + ])('formats %o as %s', (address, expected) => { + expect(formatAddress(address)).toBe(expected); + }); +}); + +describe('getNetworkPeerAddress', () => { + it.each([ + ['network.peer.address', 'network.peer.port'], + ['net.sock.peer.addr', 'net.sock.peer.port'], + ['net.peer.ip', 'net.peer.port'], + ])('reads %s with %s', (address, port) => { + expect(getNetworkPeerAddress({ [address]: '10.0.0.1', [port]: 80 })).toBe( + '10.0.0.1:80', + ); + }); + + it('prefers the newest pair and never takes a port from another pair', () => { + expect( + getNetworkPeerAddress({ + 'network.peer.address': '10.0.0.1', + 'net.sock.peer.addr': '10.0.0.2', + 'net.sock.peer.port': '51514', + }), + ).toBe('10.0.0.1'); + }); +}); + +describe('getLocalAddress', () => { + it.each([ + ['network.local.address', 'network.local.port', '10.0.0.1:80'], + ['net.sock.host.addr', 'net.sock.host.port', '10.0.0.1:80'], + ['net.host.ip', 'net.host.port', '10.0.0.1'], + ])('reads %s with %s', (address, port, expected) => { + expect(getLocalAddress({ [address]: '10.0.0.1', [port]: 80 })).toBe( + expected, + ); + }); +}); + +describe('getClientAddress', () => { + it.each([ + [ + 'client.address with its port', + { + 'client.address': '203.0.113.9', + 'client.port': '65123', + 'http.client_ip': '198.51.100.1', + }, + '203.0.113.9:65123', + ], + ['http.client_ip', { 'http.client_ip': '198.51.100.1' }, '198.51.100.1'], + [ + 'the client behind a proxy', + { 'client.address': '203.0.113.9', 'network.peer.address': '10.0.0.5' }, + '203.0.113.9', + ], + [ + 'nothing when the client is the peer, ignoring ports', + { + 'client.address': '::1', + 'network.peer.address': '::1', + 'network.peer.port': '49347', + }, + undefined, + ], + ])('returns %s', (_case, attributes, expected) => { + expect(getClientAddress(attributes)).toBe(expected); + }); +}); diff --git a/packages/app/src/utils/curlGenerator.ts b/packages/app/src/utils/curlGenerator.ts index 61fc32b07a..81f158ebdf 100644 --- a/packages/app/src/utils/curlGenerator.ts +++ b/packages/app/src/utils/curlGenerator.ts @@ -1,7 +1,5 @@ // From: https://github.com/albertodeago/curl-generator/blob/master/src/main.ts -// Forked to allow multiple headers with the same name - -type StringMap = { [key: string]: string }; +// Forked to allow repeated header names and any HTTP method /** * Additional options for curl command. @@ -50,17 +48,7 @@ type CurlAdditionalOptions = { }; type CurlRequest = { - method?: - | 'GET' - | 'get' - | 'POST' - | 'post' - | 'PUT' - | 'put' - | 'PATCH' - | 'patch' - | 'DELETE' - | 'delete'; + method?: string; headers?: [string, string][]; body?: object | string; url: string; @@ -77,20 +65,16 @@ const newLine = '\n'; const getCurlMethod = function (method?: string): string { let result = ''; if (method) { - const types: StringMap = { - GET: '-X GET', - POST: '-X POST', - PUT: '-X PUT', - PATCH: '-X PATCH', - DELETE: '-X DELETE', - }; - result = ` ${types[method.toUpperCase()]}`; + const upper = method.toUpperCase(); + // The method comes from telemetry, so quote anything that isn't a plain + // token before it reaches a shell. + result = ` -X ${/^[A-Z0-9_-]+$/.test(upper) ? upper : escapeStringPosix(upper)}`; } return slash + newLine + result; }; /** - * @param {StringMap} headers + * @param {[string, string][]} headers * @returns {string} */ const getCurlHeaders = function (headers?: [string, string][]): string { diff --git a/packages/app/src/utils/httpHeaders.ts b/packages/app/src/utils/httpHeaders.ts new file mode 100644 index 0000000000..7ddb659ab0 --- /dev/null +++ b/packages/app/src/utils/httpHeaders.ts @@ -0,0 +1,94 @@ +import type { Attributes } from './spanAttributeReaders'; + +export type HttpHeader = { + /** Display name. Never use it to query the attribute; use `attributeKey`. */ + name: string; + value: string; + attributeKey: string; +}; + +export const HTTP_REQUEST_HEADER_PREFIX = 'http.request.header.'; +export const HTTP_RESPONSE_HEADER_PREFIX = 'http.response.header.'; + +// Trace context headers would attach a replayed request to the trace it was +// captured from. curl derives Host and Content-Length from the URL and body, +// and a captured Content-Length can be stale when the body was truncated or +// redacted. Connection-level headers don't apply to a new connection. +const HEADERS_NOT_REPLAYED = + /^(traceparent|tracestate|b3|x-b3-.*|uber-trace-id|x-amzn-trace-id|x-cloud-trace-context|sentry-trace|x-datadog-.*|host|content-length|connection|keep-alive|transfer-encoding)$/i; + +// Map columns store the semconv `string[]` header value as a JSON string. +// Only arrays are parsed so numeric or quoted header values stay verbatim. +function headerValues(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'string' && value.trimStart().startsWith('[')) { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // not JSON, keep the raw string + } + } + return [value]; +} + +/** + * Header attributes keyed by `prefix` (e.g. `http.request.header.`), one entry + * per value. Array values (the stable semconv shape) become one entry each. + * + * Names swap every `_` for `-` for display: OTel Python sends + * `http.request.header.x_forwarded_for`. The attribute key is untouched in + * `attributeKey`, which is what any query or filter must use. + */ +export function parseHttpHeaders( + attributes: Attributes, + prefix: string, +): HttpHeader[] { + return Object.entries(attributes) + .filter(([key]) => key.startsWith(prefix)) + .flatMap(([attributeKey, value]) => { + const name = attributeKey.slice(prefix.length).replace(/_/g, '-'); + return headerValues(value).map(v => ({ + name, + value: v !== null && typeof v === 'object' ? JSON.stringify(v) : `${v}`, + attributeKey, + })); + }); +} + +/** + * `content-type` -> `Content-Type`, the casing HTTP/1.1 tools show. Header + * names are case-insensitive, and semconv stores them lowercase, so this only + * changes how names are shown (header tables and cURL). Not for gRPC + * metadata, whose keys are lowercase by protocol. + */ +export function toHeaderDisplayCase(header: HttpHeader): HttpHeader { + return { + ...header, + name: header.name.replace( + /(^|-)([a-z])/g, + (_, separator: string, letter: string) => + `${separator}${letter.toUpperCase()}`, + ), + }; +} + +/** First non-empty value of a header. `name` must be lowercase. */ +export function firstHttpHeaderValue( + attributes: Attributes, + prefix: string, + name: string, +): string | undefined { + return parseHttpHeaders(attributes, prefix).find( + header => header.name.toLowerCase() === name && header.value !== '', + )?.value; +} + +/** Request headers worth sending again when the request is replayed. */ +export function getReplayableHeaders(headers: HttpHeader[]): HttpHeader[] { + return headers.filter(header => !HEADERS_NOT_REPLAYED.test(header.name)); +} diff --git a/packages/app/src/utils/httpSpanAttributes.ts b/packages/app/src/utils/httpSpanAttributes.ts new file mode 100644 index 0000000000..ca1acc1b42 --- /dev/null +++ b/packages/app/src/utils/httpSpanAttributes.ts @@ -0,0 +1,224 @@ +// Reads HTTP span attributes across OpenTelemetry semantic convention +// versions. Each getter prefers the current name and falls back to deprecated +// and SDK-specific ones, so spans from older instrumentation still render. +// https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + +import { formatNumber } from '@/utils'; + +import { + firstHttpHeaderValue, + HTTP_REQUEST_HEADER_PREFIX, +} from './httpHeaders'; +import { + Attributes, + firstSetNumber, + firstSetString, + isSet, + toFiniteNumber, +} from './spanAttributeReaders'; + +type HttpDirection = 'request' | 'response'; + +const HTTP_SPAN_KEYS = [ + 'url.full', + 'url.path', + 'http.request.method', + 'http.response.status_code', + 'http.url', + // Pre-1.21 server spans often carry no http.url, so the old method, status + // and target keys mark them too. + 'http.method', + 'http.status_code', + 'http.target', +]; + +// Common codes only; the badge shows any other code without a phrase. +const REASON_PHRASES: Readonly> = { + 200: 'OK', + 201: 'Created', + 204: 'No Content', + 301: 'Moved Permanently', + 302: 'Found', + 304: 'Not Modified', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 409: 'Conflict', + 422: 'Unprocessable Content', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', +}; + +export function isHttpSpan(attributes: Attributes): boolean { + return HTTP_SPAN_KEYS.some(key => isSet(attributes[key])); +} + +export function getHttpMethod(attributes: Attributes): string | undefined { + const method = firstSetString(attributes, [ + 'http.request.method', + 'http.method', + ]); + // Instrumentations report methods they don't recognize as `_OTHER` and keep + // the real one in `http.request.method_original`. + if (method === '_OTHER') { + return ( + firstSetString(attributes, ['http.request.method_original']) ?? method + ); + } + return method; +} + +export function getHttpStatusCode(attributes: Attributes): number | undefined { + return firstSetNumber(attributes, [ + 'http.response.status_code', + 'http.status_code', + ]); +} + +/** + * `http.status_text` from older OTel JS instrumentations (the legacy HyperDX + * SDKs), else a common reason phrase. + */ +export function getHttpStatusText( + attributes: Attributes, + statusCode: number | undefined, +): string | undefined { + return ( + firstSetString(attributes, ['http.status_text']) ?? + (statusCode != null ? REASON_PHRASES[statusCode] : undefined) + ); +} + +export function getHttpRoute(attributes: Attributes): string | undefined { + return firstSetString(attributes, ['http.route', 'url.template']); +} + +export function getHttpProtocol(attributes: Attributes): string | undefined { + const version = firstSetString(attributes, [ + 'network.protocol.version', + 'net.protocol.version', + 'http.flavor', + ]); + if (version == null) { + return undefined; + } + // `http.flavor` also allowed bare names such as "QUIC" and "SPDY". + if (!/^\d/.test(version)) { + return version; + } + const name = + firstSetString(attributes, [ + 'network.protocol.name', + 'net.protocol.name', + ]) ?? 'http'; + // `http.flavor` wrote "2.0" and "3.0"; current semconv writes "2" and "3". + return `${name.toUpperCase()}/${version.replace(/^([23])\.0$/, '$1')}`; +} + +// OTel Python's gRPC server instrumentation reports `rpc.user_agent`. +export function getUserAgent(attributes: Attributes): string | undefined { + return ( + firstSetString(attributes, [ + 'user_agent.original', + 'rpc.user_agent', + 'http.user_agent', + ]) ?? + firstHttpHeaderValue(attributes, HTTP_REQUEST_HEADER_PREFIX, 'user-agent') + ); +} + +// One decimal above 1 KB, so a body and total of 1536 and 1900 bytes don't +// both round to "2 KB". +function formatBytes(bytes: number): string { + return formatNumber(bytes, { + output: 'byte', + mantissa: Math.abs(bytes) >= 1024 ? 1 : 0, + }); +} + +// `*_content_length_uncompressed` comes last: it is the decoded size, while +// `body.size` is the size on the wire. +function getBodySize( + attributes: Attributes, + direction: HttpDirection, +): number | undefined { + return ( + firstSetNumber(attributes, [ + `http.${direction}.body.size`, + `http.${direction}_content_length`, + ]) ?? + toFiniteNumber( + firstHttpHeaderValue( + attributes, + `http.${direction}.header.`, + 'content-length', + ), + ) ?? + firstSetNumber(attributes, [ + `http.${direction}_content_length_uncompressed`, + ]) + ); +} + +/** + * Body bytes and total bytes (start line, headers and body), e.g. + * "1.5 KB body · 1.9 KB total". + */ +export function getHttpMessageSize( + attributes: Attributes, + direction: HttpDirection, +): string | undefined { + const body = getBodySize(attributes, direction); + const total = firstSetNumber(attributes, [`http.${direction}.size`]); + const parts = [ + body != null ? `${formatBytes(body)} body` : undefined, + total != null ? `${formatBytes(total)} total` : undefined, + ]; + return parts.filter(part => part != null).join(' · ') || undefined; +} + +export function getHttpResendCount(attributes: Attributes): number | undefined { + return firstSetNumber(attributes, [ + 'http.request.resend_count', + 'http.resend_count', + 'http.retry_count', + ]); +} + +// Bodies keep '' so the panel can tell an empty body from one that wasn't +// collected. +export function getHttpBody( + attributes: Attributes, + direction: HttpDirection, +): unknown { + return ( + attributes[`http.${direction}.body.content`] ?? + attributes[`http.${direction}.body`] + ); +} + +/** + * E.g. "8.0 KB of 23.3 KB" for a body shorter than its reported size. The + * ClickStack Node and Python SDKs cut captured bodies at 8192 bytes + * (configurable in Python) without flagging it, but report the full size in + * `http.*.body.size`. A redacted body can also come out shorter than that + * size. + */ +export function getHttpBodyTruncation( + attributes: Attributes, + direction: HttpDirection, +): string | undefined { + const body = getHttpBody(attributes, direction); + const total = firstSetNumber(attributes, [`http.${direction}.body.size`]); + if (typeof body !== 'string' || total == null) { + return undefined; + } + const shown = new TextEncoder().encode(body).length; + return total > shown + ? `${formatBytes(shown)} of ${formatBytes(total)}` + : undefined; +} diff --git a/packages/app/src/utils/httpUrl.ts b/packages/app/src/utils/httpUrl.ts new file mode 100644 index 0000000000..10fc2ece95 --- /dev/null +++ b/packages/app/src/utils/httpUrl.ts @@ -0,0 +1,124 @@ +// Rebuilds the URL of an HTTP span across semantic convention versions: +// client spans send it whole, server spans in parts. +// https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + +import { + AddressKeys, + Attributes, + firstAddress, + firstSetString, +} from './spanAttributeReaders'; + +const DEFAULT_PORTS: Record = { http: '80', https: '443' }; + +// `http.host` is the Host header, so it carries its own port when the port +// isn't the default one. +const URL_HOST_KEYS: AddressKeys[] = [ + ['server.address', 'server.port'], + ['http.host'], + ['net.host.name', 'net.host.port'], + ['http.server_name', 'net.host.port'], +]; + +// Pre-1.21 client spans could name the host in net.peer.* instead of +// http.host. It goes last because on server spans net.peer.name is the +// client, and a compliant server span always has one of the keys above. +const DEPRECATED_URL_HOST_KEYS: AddressKeys[] = [ + ...URL_HOST_KEYS, + ['net.peer.name', 'net.peer.port'], +]; + +// OTel Python can put the port in server.address (WSGI copies the Host +// header, ASGI writes the socket's host:port), so only a bare host gets +// server.port added. +function formatHost( + address: string, + port: string | undefined, + scheme: string | undefined, +): string { + const colons = address.split(':').length - 1; + const hasPort = + /^\[.*\]:\d+$/.test(address) || (colons === 1 && /:\d+$/.test(address)); + if (hasPort) { + return address; + } + + const host = + colons >= 2 && !address.startsWith('[') ? `[${address}]` : address; + const showPort = + port != null && port !== DEFAULT_PORTS[scheme?.toLowerCase() ?? '']; + return `${host}${showPort ? `:${port}` : ''}`; +} + +export function isAbsoluteHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +function absoluteOrUndefined(url: string | undefined) { + return url != null && isAbsoluteHttpUrl(url) ? url : undefined; +} + +function joinUrl( + attributes: Attributes, + target: string, + hostKeys: AddressKeys[], +): string { + const scheme = firstSetString(attributes, ['url.scheme', 'http.scheme']); + const host = firstAddress(attributes, hostKeys); + let origin = ''; + if (host != null) { + origin = `${scheme != null ? `${scheme}://` : ''}${formatHost(host.address, host.port, scheme)}`; + } + + const separator = origin && !target.startsWith('/') ? '/' : ''; + return `${origin}${separator}${target}`; +} + +// Server spans carry the URL in parts instead of `url.full`. +function urlFromStableParts(attributes: Attributes): string | undefined { + const path = firstSetString(attributes, ['url.path']); + if (path == null) { + return undefined; + } + const query = firstSetString(attributes, ['url.query'])?.replace(/^\?/, ''); + return joinUrl( + attributes, + `${path}${query ? `?${query}` : ''}`, + URL_HOST_KEYS, + ); +} + +// Before v1.21 the path and query arrived together in `http.target`. +function urlFromDeprecatedParts(attributes: Attributes): string | undefined { + const target = firstSetString(attributes, ['http.target']); + if (target == null) { + return undefined; + } + // Requests sent to a forward proxy put the absolute URL in the target. + if (isAbsoluteHttpUrl(target)) { + return target; + } + return joinUrl(attributes, target, DEPRECATED_URL_HOST_KEYS); +} + +/** + * `url.full`, else a URL rebuilt from the stable parts, else `http.url`, else + * one rebuilt from `http.target`. + * + * `http.url` beats the deprecated parts: the instrumentation built it from the + * same request, while the parts can be incomplete (OTel JS's Node http client + * spans send no `http.scheme`, and ASGI's `http.target` drops the query). Any + * rebuild missing a scheme or host loses to `http.url` too. + */ +export function getHttpUrl(attributes: Attributes): string | undefined { + const stable = urlFromStableParts(attributes); + const deprecated = urlFromDeprecatedParts(attributes); + return ( + firstSetString(attributes, ['url.full']) ?? + absoluteOrUndefined(stable) ?? + firstSetString(attributes, ['http.url']) ?? + absoluteOrUndefined(deprecated) ?? + stable ?? + deprecated + ); +} diff --git a/packages/app/src/utils/rpcSpanAttributes.ts b/packages/app/src/utils/rpcSpanAttributes.ts new file mode 100644 index 0000000000..8b2eaaeeec --- /dev/null +++ b/packages/app/src/utils/rpcSpanAttributes.ts @@ -0,0 +1,203 @@ +// Reads RPC span attributes across semconv versions, plus the `grpc.method` +// and `grpc.status_code` tags grpc-dotnet sets on ASP.NET Core's HTTP server +// span and Grpc.Net.Client's call span. Only gRPC status codes are named; +// other systems' statuses are shown as emitted. +// https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/ + +import uniqBy from 'lodash/uniqBy'; + +import { HttpHeader, parseHttpHeaders } from './httpHeaders'; +import { isHttpSpan } from './httpSpanAttributes'; +import { + AddressKeys, + Attributes, + firstSetString, + formatFirstAddress, + isSet, +} from './spanAttributeReaders'; + +const RPC_SYSTEM_KEYS = ['rpc.system.name', 'rpc.system']; + +const GRPC_KEYS = ['grpc.method', 'grpc.status_code', 'rpc.grpc.status_code']; + +// Dubbo, Connect and JSON-RPC set these too, so on their own they only mark a +// generic RPC call. +const GENERIC_RPC_KEYS = ['rpc.method', 'rpc.service']; + +// Newest name first: semconv main (unreleased) renames +// `rpc.response.status_code` to `rpc.status_code`, and v1.39 replaced +// `rpc.grpc.status_code`. grpc-dotnet sets `grpc.status_code` and may also set +// `grpc.status`. +const RPC_STATUS_KEYS = [ + 'rpc.status_code', + 'rpc.response.status_code', + 'rpc.grpc.status_code', + 'grpc.status_code', + 'grpc.status', +]; + +// Index is the numeric code. +// https://github.com/grpc/grpc/blob/master/doc/statuscodes.md +const GRPC_STATUS_NAMES: readonly string[] = [ + 'OK', + 'CANCELLED', + 'UNKNOWN', + 'INVALID_ARGUMENT', + 'DEADLINE_EXCEEDED', + 'NOT_FOUND', + 'ALREADY_EXISTS', + 'PERMISSION_DENIED', + 'RESOURCE_EXHAUSTED', + 'FAILED_PRECONDITION', + 'ABORTED', + 'OUT_OF_RANGE', + 'UNIMPLEMENTED', + 'INTERNAL', + 'UNAVAILABLE', + 'DATA_LOSS', + 'UNAUTHENTICATED', +]; + +const SERVER_KEYS: AddressKeys[] = [['server.address', 'server.port']]; + +// Before v1.21 client spans named the server in net.peer.*. On server spans +// those keys name the caller. +const CLIENT_SPAN_SERVER_KEYS: AddressKeys[] = [ + ...SERVER_KEYS, + ['net.peer.name', 'net.peer.port'], +]; + +// Semconv main (unreleased) renames the v1.39 `metadata` keys to `header` +// and splits response metadata into headers and trailers. +const METADATA_PREFIXES = { + request: [ + 'rpc.request.header.', + 'rpc.request.metadata.', + 'rpc.grpc.request.metadata.', + ], + response: [ + 'rpc.response.header.', + 'rpc.response.metadata.', + 'rpc.grpc.response.metadata.', + 'rpc.response.trailer.', + ], +}; + +export type RpcStatus = { + /** gRPC only. */ + code?: number; + /** The gRPC status name, or the value as emitted. */ + name: string; + /** Badge text, e.g. "5 NOT_FOUND". */ + label: string; + isOk: boolean; +}; + +function isGrpcSpan(attributes: Attributes): boolean { + return ( + firstSetString(attributes, RPC_SYSTEM_KEYS) === 'grpc' || + GRPC_KEYS.some(key => isSet(attributes[key])) + ); +} + +export function isRpcSpan(attributes: Attributes): boolean { + return [...RPC_SYSTEM_KEYS, ...GRPC_KEYS, ...GENERIC_RPC_KEYS].some(key => + isSet(attributes[key]), + ); +} + +/** Title of the overview's request panel; undefined when there is no panel. */ +export function getRequestPanelTitle( + attributes: Attributes, +): string | undefined { + if (isGrpcSpan(attributes)) { + return 'gRPC Call'; + } + if (isRpcSpan(attributes)) { + return 'RPC Call'; + } + return isHttpSpan(attributes) ? 'HTTP Request' : undefined; +} + +export function getRpcSystemLabel(attributes: Attributes): string { + if (isGrpcSpan(attributes)) { + return 'gRPC'; + } + return firstSetString(attributes, RPC_SYSTEM_KEYS) ?? 'RPC'; +} + +const stripLeadingSlash = (value: string) => value.replace(/^\//, ''); + +/** Fully qualified method, e.g. `oteldemo.CartService/EmptyCart`. */ +export function getRpcMethod(attributes: Attributes): string | undefined { + const method = firstSetString(attributes, ['rpc.method']); + if (method === '_OTHER') { + return firstSetString(attributes, ['rpc.method_original']) ?? method; + } + if (method != null) { + // Before v1.39 `rpc.method` held only the short name and `rpc.service` + // the service. + const service = firstSetString(attributes, ['rpc.service']); + return stripLeadingSlash( + service != null && !method.includes('/') + ? `${service}/${method}` + : method, + ); + } + // A gRPC request's path is `/{service}/{method}`. It beats .NET's `other`, + // which stands for a method the server doesn't know. + const grpcMethod = firstSetString(attributes, ['grpc.method']); + const path = isGrpcSpan(attributes) + ? firstSetString(attributes, ['url.path']) + : undefined; + const fallback = + grpcMethod === 'other' ? (path ?? grpcMethod) : (grpcMethod ?? path); + return fallback != null ? stripLeadingSlash(fallback) : undefined; +} + +export function getRpcStatus(attributes: Attributes): RpcStatus | undefined { + const value = firstSetString(attributes, RPC_STATUS_KEYS)?.trim(); + if (value == null) { + return undefined; + } + // Every system has its own codes: a JSON-RPC 5 is not gRPC NOT_FOUND. + if (isGrpcSpan(attributes)) { + const code = /^\d+$/.test(value) + ? Number(value) + : GRPC_STATUS_NAMES.indexOf(value.toUpperCase()); + const name = GRPC_STATUS_NAMES[code]; + if (name != null) { + return { code, name, label: `${code} ${name}`, isOk: code === 0 }; + } + } + return { name: value, label: value, isOk: /^(ok|0)$/i.test(value) }; +} + +/** + * The server the client called, e.g. `quote:8080`. `spanKind` is the + * SpanKind column, which the ClickHouse exporter writes as `Server` and older + * versions as `SPAN_KIND_SERVER`. + */ +export function getRpcServerAddress( + attributes: Attributes, + spanKind?: string, +): string | undefined { + const isServerSpan = spanKind === 'Server' || spanKind === 'SPAN_KIND_SERVER'; + return formatFirstAddress( + attributes, + isServerSpan ? SERVER_KEYS : CLIENT_SPAN_SERVER_KEYS, + ); +} + +/** RPC metadata as header rows, response trailers included. */ +export function getRpcMetadata( + attributes: Attributes, + direction: keyof typeof METADATA_PREFIXES, +): HttpHeader[] { + const entries = METADATA_PREFIXES[direction].flatMap(prefix => + parseHttpHeaders(attributes, prefix), + ); + // An emitter migrating between versions may send the same entry under more + // than one prefix. + return uniqBy(entries, ({ name, value }) => JSON.stringify([name, value])); +} diff --git a/packages/app/src/utils/spanAttributeReaders.ts b/packages/app/src/utils/spanAttributeReaders.ts new file mode 100644 index 0000000000..e5b545a3d8 --- /dev/null +++ b/packages/app/src/utils/spanAttributeReaders.ts @@ -0,0 +1,118 @@ +// Readers shared by the HTTP and RPC span attribute getters. Map columns +// store every value as a string, so the readers treat '' as missing and parse +// numbers out of strings. +// https://opentelemetry.io/docs/specs/semconv/general/attributes/ + +export type Attributes = Record; + +type NetworkAddress = { address: string; port?: string }; + +/** An address attribute and, optionally, the port attribute paired with it. */ +export type AddressKeys = readonly [address: string, port?: string]; + +const PEER_ADDRESS_KEYS: AddressKeys[] = [ + ['network.peer.address', 'network.peer.port'], + ['net.sock.peer.addr', 'net.sock.peer.port'], + ['net.peer.ip', 'net.peer.port'], +]; + +const CLIENT_ADDRESS_KEYS: AddressKeys[] = [ + ['client.address', 'client.port'], + ['http.client_ip'], +]; + +const LOCAL_ADDRESS_KEYS: AddressKeys[] = [ + ['network.local.address', 'network.local.port'], + ['net.sock.host.addr', 'net.sock.host.port'], + ['net.host.ip'], +]; + +export function isSet(value: unknown): boolean { + return value != null && value !== ''; +} + +export function firstSetString( + attributes: Attributes, + keys: string[], +): string | undefined { + const key = keys.find(key => isSet(attributes[key])); + return key != null ? String(attributes[key]) : undefined; +} + +export function toFiniteNumber(value: unknown): number | undefined { + const number = Number(value); + return isSet(value) && Number.isFinite(number) ? number : undefined; +} + +export function firstSetNumber( + attributes: Attributes, + keys: string[], +): number | undefined { + return toFiniteNumber(firstSetString(attributes, keys)); +} + +/** + * The first address that is set, with the port from the same pair. Pairs are + * read together so a port from one semconv version is never attached to an + * address from another. + */ +export function firstAddress( + attributes: Attributes, + pairs: readonly AddressKeys[], +): NetworkAddress | undefined { + for (const [addressKey, portKey] of pairs) { + const address = firstSetString(attributes, [addressKey]); + if (address != null) { + const port = + portKey != null ? firstSetString(attributes, [portKey]) : undefined; + return { address, port }; + } + } + return undefined; +} + +// Brackets follow RFC 3986, the same way browsers show a remote IPv6 address. +export function formatAddress({ address, port }: NetworkAddress): string { + // Unix domain sockets report a file path and have no port. + if (port == null || address.startsWith('/')) { + return address; + } + const host = + address.includes(':') && !address.startsWith('[') + ? `[${address}]` + : address; + return `${host}:${port}`; +} + +export function formatFirstAddress( + attributes: Attributes, + pairs: readonly AddressKeys[], +): string | undefined { + const address = firstAddress(attributes, pairs); + return address != null ? formatAddress(address) : undefined; +} + +/** The socket peer: a proxy or load balancer when there is one. */ +export function getNetworkPeerAddress( + attributes: Attributes, +): string | undefined { + return formatFirstAddress(attributes, PEER_ADDRESS_KEYS); +} + +/** + * The original client, which server spans resolve through proxy headers. + * Undefined when it is the socket peer, i.e. there is no proxy. The + * comparison ignores ports: servers usually report the peer port but not the + * client port. + */ +export function getClientAddress(attributes: Attributes): string | undefined { + const client = firstAddress(attributes, CLIENT_ADDRESS_KEYS); + const peer = firstAddress(attributes, PEER_ADDRESS_KEYS); + return client != null && client.address !== peer?.address + ? formatAddress(client) + : undefined; +} + +export function getLocalAddress(attributes: Attributes): string | undefined { + return formatFirstAddress(attributes, LOCAL_ADDRESS_KEYS); +} From b7b22debfbdb78c4209ca8c7675dc0a4a06dee7e Mon Sep 17 00:00:00 2001 From: Spencer Torres Date: Mon, 19 Oct 2026 16:20:15 -0700 Subject: [PATCH 2/3] fix: bit of padding to align highlight with caret --- packages/app/src/components/HyperJson.module.scss | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/HyperJson.module.scss b/packages/app/src/components/HyperJson.module.scss index 80d10d549e..1c4555baf3 100644 --- a/packages/app/src/components/HyperJson.module.scss +++ b/packages/app/src/components/HyperJson.module.scss @@ -31,7 +31,7 @@ .line { display: flex; gap: 10px; - padding: 2px 8px; + padding: 2px 8px 2px 20px; align-items: flex-start; border-radius: 3px; position: relative; @@ -56,11 +56,10 @@ cursor: pointer; .key { - margin-left: -10px; + margin-left: -16px; } .valueContainer { - margin-left: 10px; word-break: break-all; } From d9e35b37e00d34085f7b69dc088fb53ec0f189b6 Mon Sep 17 00:00:00 2001 From: Spencer Torres Date: Mon, 19 Oct 2026 16:41:35 -0700 Subject: [PATCH 3/3] fix: simplify tests --- packages/app/src/__tests__/utils.test.ts | 15 +- .../NetworkPropertyPanel.stories.tsx | 174 ---------- .../src/components/RpcCallPanel.stories.tsx | 97 ------ .../DBRowOverviewPanel.http.test.tsx | 120 ++----- .../__tests__/DBTraceWaterfallChart.test.tsx | 200 ++++------- .../__tests__/NetworkPropertyPanel.test.tsx | 312 ++++++++---------- .../__tests__/RpcCallPanel.test.tsx | 124 ------- .../src/utils/__tests__/curlGenerator.test.ts | 48 +-- .../src/utils/__tests__/httpHeaders.test.ts | 121 +++---- .../__tests__/httpSpanAttributes.test.ts | 212 ++++-------- .../app/src/utils/__tests__/httpUrl.test.ts | 163 +++------ .../utils/__tests__/rpcSpanAttributes.test.ts | 147 +++------ .../__tests__/spanAttributeReaders.test.ts | 112 +++---- 13 files changed, 492 insertions(+), 1353 deletions(-) delete mode 100644 packages/app/src/components/NetworkPropertyPanel.stories.tsx delete mode 100644 packages/app/src/components/RpcCallPanel.stories.tsx delete mode 100644 packages/app/src/components/__tests__/RpcCallPanel.test.tsx diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 7e7b55ef31..864d5ba1b2 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1545,16 +1545,13 @@ describe('getLogSeverityLevel', () => { ['17', undefined, 'error'], [0, 'ERROR', 'error'], [undefined, 'Warning', 'warn'], - ['', 'critical', 'error'], + ['', 'fatal', 'error'], [undefined, 'info', undefined], - ])( - 'maps SeverityNumber %p and SeverityText %p to %p', - (severityNumber, severityText, expected) => { - expect(getLogSeverityLevel({ severityNumber, severityText })).toBe( - expected, - ); - }, - ); + ])('maps %p and %p to %p', (severityNumber, severityText, expected) => { + expect(getLogSeverityLevel({ severityNumber, severityText })).toBe( + expected, + ); + }); }); describe('getColorFromCSSToken', () => { diff --git a/packages/app/src/components/NetworkPropertyPanel.stories.tsx b/packages/app/src/components/NetworkPropertyPanel.stories.tsx deleted file mode 100644 index d07c968c26..0000000000 --- a/packages/app/src/components/NetworkPropertyPanel.stories.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/nextjs'; - -import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; - -const meta = { - title: 'Components/NetworkPropertyPanel', - component: NetworkPropertySubpanel, - parameters: { layout: 'padded' }, - decorators: [ - Story => ( -
- -
- ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const semconvClientErrorAttributes = { - 'url.full': - 'https://example-api.clickhouse.com/v2/charges/ch_3Nq8xY2eZvKYlo2C1a9b/refunds?expand[]=customer&expand[]=invoice.subscription&idempotency_key=7f9c1a2e-4b3d-4e8f-9a6b-2c1d0e9f8a7b&locale=en-US&return_url=https%3A%2F%2Fshop.example.com%2Fcheckout%2Fcomplete%3Forder%3D84213', - 'http.request.method': 'POST', - 'http.response.status_code': '429', - 'network.peer.address': '10.0.4.12', - 'network.peer.port': '443', - 'network.protocol.version': '2', - 'server.address': 'example-api.clickhouse.com', - 'server.port': '443', - 'http.request.resend_count': '2', - 'error.type': '429', - 'http.request.body.size': '512', - 'http.response.body.size': '87', - 'http.request.header.content-type': '["application/json"]', - 'http.request.header.x_forwarded_for': '1.2.3.4', - 'http.response.header.retry-after': '["30"]', - 'http.request.body.content': - '{"amount":4200,"currency":"usd","reason":"requested_by_customer","metadata":{"order_id":"84213"}}', - 'http.response.body.content': - '{"error":{"type":"rate_limit_error","message":"Too many requests"}}', -}; - -export const SemconvServer: Story = { - args: { - eventAttributes: { - 'client.address': '::1', - 'http.request.method': 'GET', - 'http.response.status_code': '200', - 'http.route': '/api/hello', - 'network.peer.address': '::1', - 'network.peer.port': '49347', - 'network.protocol.version': '1.1', - 'server.address': 'localhost', - 'server.port': '3001', - 'url.path': '/api/hello', - 'url.scheme': 'http', - 'user_agent.original': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36', - }, - }, -}; - -export const SemconvClientError: Story = { - args: { eventAttributes: semconvClientErrorAttributes }, -}; - -export const ServerError: Story = { - args: { - eventAttributes: { - 'http.request.method': 'GET', - 'http.response.status_code': '503', - 'http.route': '/orders/{id}', - 'url.path': '/orders/42', - 'url.query': 'expand=items', - 'url.scheme': 'https', - 'server.address': 'example-api.clickhouse.com', - 'server.port': '443', - 'client.address': '203.0.113.9', - 'network.peer.address': '10.0.0.5', - 'network.peer.port': '38122', - 'network.protocol.version': '1.1', - 'user_agent.original': 'okhttp/4.12.0', - 'error.type': '_OTHER', - 'http.request.header.accept': '["application/json"]', - 'http.request.header.x-request-id': - '["5d0c7f3e-2a1b-4c9d-8e7f-6a5b4c3d2e1f"]', - 'http.response.header.content-type': '["application/json"]', - 'http.response.header.retry-after': '["5"]', - }, - }, -}; - -export const DeprecatedClient: Story = { - args: { - eventAttributes: { - 'http.url': 'http://example-api.clickhouse.com:8080/api/items/9921', - 'http.method': 'GET', - 'http.status_code': '404', - 'http.status_text': 'Not Found', - 'net.peer.ip': '10.0.0.2', - 'net.peer.port': '8080', - 'http.flavor': '1.1', - 'http.user_agent': 'python-requests/2.32.3', - }, - }, -}; - -export const DeprecatedServer: Story = { - args: { - eventAttributes: { - 'http.method': 'GET', - 'http.scheme': 'http', - 'http.host': 'frontend-proxy:8080', - 'http.target': '/api/products/0PUK6V6EV0?currency=USD', - 'http.status_code': '200', - 'http.status_text': 'OK', - 'net.host.name': 'frontend-proxy', - 'net.host.port': '8080', - 'net.sock.peer.addr': '10.116.11.86', - 'net.sock.peer.port': '51514', - 'http.flavor': '2.0', - 'http.user_agent': 'python-requests/2.32.3', - 'net.transport': 'ip_tcp', - }, - }, -}; - -export const FailedBrowserRequest: Story = { - args: { - eventAttributes: { - 'http.url': 'https://example-api.clickhouse.com/v1/cart', - 'http.method': 'POST', - 'http.status_code': '0', - 'http.request.body': '{"items":[1,2]}', - }, - }, -}; - -// The ClickStack Node and Python SDKs keep the first 8192 bytes of a body and -// report the full size in http.*.body.size. -export const TruncatedBody: Story = { - args: { - eventAttributes: { - 'http.request.method': 'GET', - 'http.response.status_code': '200', - 'http.route': '/api/products', - 'url.path': '/api/products', - 'url.scheme': 'http', - 'server.address': 'catalog', - 'server.port': '8080', - 'http.response.body.size': '23817', - 'http.response.body.content': JSON.stringify({ - products: Array.from({ length: 200 }, (_, i) => ({ - id: `SKU-${String(i + 1).padStart(4, '0')}`, - name: `Product ${i + 1}`, - priceUsd: { currencyCode: 'USD', units: 10 + i, nanos: 990000000 }, - })), - }).slice(0, 8192), - }, - }, -}; - -export const Narrow: Story = { - args: { eventAttributes: semconvClientErrorAttributes }, - decorators: [ - Story => ( -
- -
- ), - ], -}; diff --git a/packages/app/src/components/RpcCallPanel.stories.tsx b/packages/app/src/components/RpcCallPanel.stories.tsx deleted file mode 100644 index bdb6c7c840..0000000000 --- a/packages/app/src/components/RpcCallPanel.stories.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/nextjs'; - -import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; - -// Rendered through NetworkPropertySubpanel, the overview's entry point, so -// spans with both RPC and HTTP attributes take the same path as in the app. -// The attributes come from real OTel Node and ASP.NET Core spans. -const meta = { - title: 'Components/RpcCallPanel', - component: NetworkPropertySubpanel, - parameters: { layout: 'padded' }, - decorators: [ - Story => ( -
- -
- ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const nodeClientAttributes = { - 'rpc.method': 'GetQuote', - 'rpc.response.metadata.x_quote_source': '["pricing-v2"]', - 'rpc.service': 'demo.QuoteService', - 'rpc.system': 'grpc', - 'server.address': '127.0.0.1', - 'server.port': '3002', -}; - -export const GrpcNodeClientOk: Story = { - args: { - eventAttributes: { - ...nodeClientAttributes, - 'rpc.grpc.status_code': '0', - 'rpc.request.metadata.x_request_id': - '["f8f85da1-5fa7-4baa-a66f-69d521029ff9"]', - }, - }, -}; - -export const GrpcNodeClientError: Story = { - args: { - eventAttributes: { - ...nodeClientAttributes, - 'grpc.error_message': '5 NOT_FOUND: no quote for missing', - 'grpc.error_name': 'Error', - 'rpc.grpc.status_code': '5', - 'rpc.request.metadata.x_request_id': - '["cb88b510-b001-4477-9f37-7ce1d8299b97"]', - }, - }, -}; - -export const GrpcNodeServerErrorWithHttp: Story = { - args: { - eventAttributes: { - ...nodeClientAttributes, - 'grpc.error_message': 'no quote for missing', - 'grpc.error_name': 'Error', - 'http.request.header.content-type': '["application/grpc"]', - 'http.request.method': 'POST', - 'http.response.status_code': '200', - 'http.route': '/demo.QuoteService/GetQuote', - 'network.protocol.name': 'http', - 'network.protocol.version': '2', - 'rpc.grpc.status_code': '5', - 'rpc.request.metadata.x_request_id': - '["cb88b510-b001-4477-9f37-7ce1d8299b97"]', - 'url.path': '/demo.QuoteService/GetQuote', - 'url.scheme': 'http', - 'user_agent.original': 'grpc-node-js/1.14.6', - }, - }, -}; - -export const GrpcAspNetCoreServer: Story = { - args: { - eventAttributes: { - 'grpc.method': '/demo.QuoteService/GetQuote', - 'grpc.status_code': '0', - 'http.request.method': 'POST', - 'http.response.status_code': '200', - 'http.route': '/demo.QuoteService/GetQuote', - 'network.protocol.version': '2', - 'server.address': 'quote', - 'server.port': '8080', - 'url.path': '/demo.QuoteService/GetQuote', - 'url.scheme': 'http', - 'user_agent.original': - 'grpc-dotnet/2.67.0 (.NET 9.0.0; CLR 9.0.0; net9.0; osx; arm64)', - }, - }, -}; diff --git a/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx b/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx index 0ec3180a89..9a69acb91a 100644 --- a/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx +++ b/packages/app/src/components/__tests__/DBRowOverviewPanel.http.test.tsx @@ -2,138 +2,72 @@ import React from 'react'; import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { screen } from '@testing-library/react'; -const mockUseRowData = jest.fn(); +import { RowOverviewPanel } from '@/components/DBRowOverviewPanel'; + +let mockRow: Record; jest.mock('../DBRowDataPanel', () => ({ - __esModule: true, - useRowData: (args: unknown) => mockUseRowData(args), + useRowData: () => ({ data: { data: [mockRow] } }), getJSONColumnNames: () => [], getMapColumnNames: () => [], })); -jest.mock('../DBRowSidePanel', () => { - const { createContext } = jest.requireActual('react'); - return { - __esModule: true, - RowSidePanelContext: createContext({}), - }; -}); - const mockDBRowJsonViewer = jest.fn(); jest.mock('../DBRowJsonViewer', () => ({ - __esModule: true, - DBRowJsonViewer: (props: unknown) => { - mockDBRowJsonViewer(props); - return null; - }, + DBRowJsonViewer: (props: unknown) => mockDBRowJsonViewer(props), })); + const mockNetworkPropertySubpanel = jest.fn(); jest.mock('../NetworkPropertyPanel', () => ({ - __esModule: true, - NetworkPropertySubpanel: (props: unknown) => { - mockNetworkPropertySubpanel(props); - return
; - }, + NetworkPropertySubpanel: (props: unknown) => + mockNetworkPropertySubpanel(props), })); + jest.mock('../linkedSpans', () => ({ - __esModule: true, useReverseSpanLinks: () => ({ links: [] }), useLinkedSpanDetails: () => ({ details: new Map() }), })); -import { RowOverviewPanel } from '@/components/DBRowOverviewPanel'; - -const TRACE_SOURCE: TSource = { - id: 'trace-src', - name: 'Traces', +// `any`, not `as`, keeps this partial fixture off the lint warning budget. +const asSource = (value: any): TSource => value; +const TRACE_SOURCE = asSource({ kind: SourceKind.Trace, - connection: 'conn-1', - from: { databaseName: 'default', tableName: 'otel_traces' }, - timestampValueExpression: 'Timestamp', - defaultTableSelectExpression: 'Timestamp, SpanName', - traceIdExpression: 'TraceId', - spanIdExpression: 'SpanId', - parentSpanIdExpression: 'ParentSpanId', - spanNameExpression: 'SpanName', - spanKindExpression: 'SpanKind', - durationExpression: 'Duration', - durationPrecision: 9, eventAttributesExpression: 'SpanAttributes', -}; +}); -function renderWithAttributes( - attributes: Record, - flattenedAttributes: Record = attributes, -) { - mockUseRowData.mockReturnValue({ - data: { - data: [ - { - SpanAttributes: attributes, - __hdx_event_attributes: flattenedAttributes, - __hdx_span_kind: 'Server', - }, - ], - meta: [], - }, - }); - renderWithMantine( - , - ); +function renderSpan(attributes: Record) { + mockRow = { + SpanAttributes: attributes, + __hdx_event_attributes: attributes, + __hdx_span_kind: 'Server', + }; + renderWithMantine(); } describe('RowOverviewPanel request section', () => { - beforeEach(() => { - mockUseRowData.mockReset(); - mockDBRowJsonViewer.mockReset(); - mockNetworkPropertySubpanel.mockReset(); - }); + beforeEach(() => jest.clearAllMocks()); it.each([ ['HTTP Request', { 'url.path': '/users', 'http.request.method': 'GET' }], // Pre-1.21 server spans often carry no http.url. ['HTTP Request', { 'http.method': 'GET', 'http.target': '/users' }], ['gRPC Call', { 'rpc.system': 'grpc', 'rpc.method': 'GetQuote' }], - [ - 'gRPC Call', - { 'grpc.method': '/x.Cart/Empty', 'url.path': '/x.Cart/Empty' }, - ], ['RPC Call', { 'rpc.service': 'S3', 'rpc.method': 'PutObject' }], ])('opens the "%s" panel for %o', (title, attributes) => { - renderWithAttributes(attributes); + renderSpan(attributes); expect(screen.getByText(title)).toBeInTheDocument(); expect(mockNetworkPropertySubpanel).toHaveBeenCalledWith({ eventAttributes: attributes, spanKind: 'Server', }); - }); - - it('detects HTTP spans from a JSON attributes column', () => { - renderWithAttributes( - { url: { path: '/users' }, http: { request: { method: 'GET' } } }, - { 'url.path': '/users', 'http.request.method': 'GET' }, + // The panel no longer strips http.* keys from the attributes viewer. + expect(mockDBRowJsonViewer).toHaveBeenCalledWith( + expect.objectContaining({ data: { SpanAttributes: attributes } }), ); - - expect(screen.getByText('HTTP Request')).toBeInTheDocument(); }); it('hides the panel for a span that is neither HTTP nor RPC', () => { - renderWithAttributes({ 'db.system': 'clickhouse' }); - - expect(screen.queryByTestId('network-subpanel')).not.toBeInTheDocument(); - }); - - it('keeps http.* attributes in the span attributes viewer', () => { - const attributes = { - 'url.full': 'https://example-app.clickhouse.com/users', - 'http.request.method': 'GET', - 'http.request.header.x_forwarded_for': '1.2.3.4', - }; - renderWithAttributes(attributes); - - expect(screen.getByTestId('network-subpanel')).toBeInTheDocument(); - expect(mockDBRowJsonViewer).toHaveBeenCalledWith( - expect.objectContaining({ data: { SpanAttributes: attributes } }), - ); + renderSpan({ 'db.system': 'clickhouse' }); + expect(mockNetworkPropertySubpanel).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 857ca751a5..832764f37d 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -124,17 +124,10 @@ const mockUseRowWhere = useRowWhere as jest.Mock; const mockUseColumns = useColumns as jest.Mock; const MockTimelineChart = TimelineChart as any; -// Mirrors useColumns: the query is disabled, so never loading, for an empty -// table name unless the caller overrides `enabled`. -const setupColumnsMock = (state: { - data?: Array<{ name: string; type: string }>; - isLoading?: boolean; -}) => - mockUseColumns.mockImplementation( - ({ tableName }: { tableName: string }, opts?: { enabled?: boolean }) => - (opts?.enabled ?? !!tableName) - ? { data: state.data, isLoading: state.isLoading ?? false } - : { data: undefined, isLoading: false }, +// Mirrors useColumns, which is disabled, so never loading, without a table. +const setupColumnsMock = (state: { data?: object[]; isLoading?: boolean }) => + mockUseColumns.mockImplementation(({ tableName }: { tableName: string }) => + tableName ? { isLoading: false, ...state } : { isLoading: false }, ); const selectsSeverityNumber = (query: { select: Array<{ alias?: string }> }) => @@ -749,20 +742,10 @@ describe('DBTraceWaterfallChartContainer', () => { const apiUrl = 'https://example-api.clickhouse.com/users?id=1'; it.each([ - [{ 'url.full': apiUrl }, apiUrl], - [ - { - 'url.scheme': 'https', - 'server.address': 'example-api.clickhouse.com', - 'url.path': '/users', - 'url.query': 'id=1', - }, - apiUrl, - ], - [{ 'http.url': apiUrl }, apiUrl], + [{ 'url.full': apiUrl }, `test span ${apiUrl}`], // An RPC span is named after its method, which the URL repeats. - [{ 'grpc.method': '/demo.QuoteService/GetQuote', 'url.full': apiUrl }, ''], - ])('labels a span with %p using URL %p', async (SpanAttributes, url) => { + [{ 'rpc.system.name': 'grpc', 'url.full': apiUrl }, 'test span'], + ])('labels a span with %p as %p', async (SpanAttributes, label) => { setupQueryMocks({ traceData: { data: [{ ...mockTraceData.data[0], SpanAttributes }], @@ -772,38 +755,27 @@ describe('DBTraceWaterfallChartContainer', () => { renderComponent(null); await waitForLoading(); - const [row] = MockTimelineChart.latestProps.rows; - const displayText = url ? `test span ${url}` : 'test span'; - expect(row.events[0].tooltip).toContain(`${displayText} took`); - const title = url ? `test-service ${displayText}` : 'test-service'; - expect(renderWithMantine(row.label).getByTitle(title)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); - const renderWithLogs = async (logs: Record[]) => { + // Main drew icons only for exact lowercase 'error'/'warn' and counted only + // SeverityText 'error'. + it.each([ + [{ SeverityNumber: 17, SeverityText: 'info' }, ['Error'], '1 error'], + [{ SeverityText: 'Warning' }, ['Warning'], '0 errors'], + [{}, [], '0 errors'], + ])('marks log %p with icons %p and %s', async (log, icons, errors) => { setupQueryMocks({ traceData: mockTraceData, logData: { - data: logs.map(log => ({ - ...mockLogData.data[0], - SeverityText: undefined, - ...log, - })), - meta: [{ totalCount: logs.length }], + data: [{ ...mockLogData.data[0], SeverityText: undefined, ...log }], + meta: [{ totalCount: 1 }], }, }); renderComponent(); await waitForLoading(); - }; - - it.each([ - [{ SeverityNumber: 17, SeverityText: 'info' }, ['Error']], - [{ SeverityNumber: 9, SeverityText: 'error' }, []], - [{ SeverityNumber: 0, SeverityText: 'ERROR' }, ['Error']], - [{ SeverityText: 'Warning' }, ['Warning']], - [{}, []], - ])('marks a log with %p with icons %p', async (severity, icons) => { - await renderWithLogs([severity]); + expect(screen.getByText(errors)).toBeInTheDocument(); const logRow = MockTimelineChart.latestProps.rows.find( (row: { type?: string }) => row.type === SourceKind.Log, ); @@ -813,67 +785,38 @@ describe('DBTraceWaterfallChartContainer', () => { ).toEqual(icons); }); - it('counts error logs by SeverityNumber, falling back to SeverityText', async () => { - mockUseRowWhere.mockReturnValue((row: Record) => ({ - where: String(row.Body), - aliasWith: [], - })); - await renderWithLogs([ - { Body: 'by number', SeverityNumber: 17 }, - { Body: 'by text', SeverityText: 'FATAL' }, - { Body: 'number wins', SeverityNumber: 13, SeverityText: 'error' }, - ]); - - expect(screen.getByText('2 errors')).toBeInTheDocument(); - }); - - describe('SeverityNumber column', () => { - const logQueryCalls = () => - mockUseOffsetPaginatedQuery.mock.calls.filter( - ([query]) => query?.from?.tableName === 'log_table', - ); - - it.each([ - ['has it', [{ name: 'SeverityNumber', type: 'UInt8' }], true], - ['lacks it', [{ name: 'SeverityText', type: 'String' }], false], - ['lookup failed', undefined, false], - ])( - 'selects SeverityNumber only when the log table has it (%s)', - async (_name, data, expected) => { - setupColumnsMock({ data }); - setupQueryMocks({ traceData: mockTraceData, logData: mockLogData }); - renderComponent(); - await waitForLoading(); - - const enabled = logQueryCalls().filter(([, opts]) => opts?.enabled); - expect(enabled.length).toBeGreaterThan(0); - for (const [query] of enabled) { - expect(selectsSeverityNumber(query)).toBe(expected); - } - expect(MockTimelineChart.latestProps.rows.length).toBe(2); - }, - ); - - it('holds the log query until the columns load', () => { - setupColumnsMock({ isLoading: true }); + it.each([ + ['has it', [true], { data: [{ name: 'SeverityNumber', type: 'UInt8' }] }], + ['lacks it', [false], { data: [{ name: 'SeverityText', type: 'String' }] }], + // A failed lookup still shows logs, just without SeverityNumber. + ['failed', [false], {}], + // Held until the columns settle so a changed select can't run it twice. + ['is loading', [], { isLoading: true }], + ])( + 'when the log column lookup %s, enabled log queries select SeverityNumber: %p', + (_name, expected, columns) => { + setupColumnsMock(columns); setupQueryMocks({ traceData: mockTraceData, logData: mockLogData }); renderComponent(); - expect(screen.getByText('Loading Traces...')).toBeInTheDocument(); - const enabled = logQueryCalls().map(([, opts]) => opts?.enabled); - expect(enabled).toContain(false); - expect(enabled).not.toContain(true); - }); + const selects = mockUseOffsetPaginatedQuery.mock.calls + .filter( + ([query, opts]) => + query?.from?.tableName === 'log_table' && opts?.enabled, + ) + .map(([query]) => selectsSeverityNumber(query)); + expect([...new Set(selects)]).toEqual(expected); + }, + ); - it('renders a trace without a log source', async () => { - // Only a lookup disabled by its empty table name skips this loading state. - setupColumnsMock({ isLoading: true }); - setupQueryMocks({ traceData: mockTraceData }); - renderComponent(null); - await waitForLoading(); + it('renders a trace without a log source', async () => { + // Without a log table the lookup is disabled, so it must not hold the chart. + setupColumnsMock({ isLoading: true }); + setupQueryMocks({ traceData: mockTraceData }); + renderComponent(null); + await waitForLoading(); - expect(MockTimelineChart.latestProps.rows.length).toBe(1); - }); + expect(MockTimelineChart.latestProps.rows).toHaveLength(1); }); it('renders Spans and Logs chips when log source is present', async () => { @@ -1114,17 +1057,6 @@ describe('useEventsAroundFocus', () => { mockUseRowWhere.mockReturnValue(() => ({ where: 'row-id', aliasWith: [] })); }); - const mockLogSource: TLogSource = { - id: 'test-log-source-id', - kind: SourceKind.Log, - name: 'log-source', - from: { databaseName: 'test_db', tableName: 'log_table' }, - timestampValueExpression: 'Timestamp', - defaultTableSelectExpression: 'Timestamp', - implicitColumnExpression: 'Body', - connection: 'conn2', - }; - const testEventsAroundFocus = (options: { beforeData?: any; afterData?: any; @@ -1167,40 +1099,28 @@ describe('useEventsAroundFocus', () => { return result.current; }; - it.each([ - ['log', true, true], - ['log', false, false], - ['trace', true, false], - ])( - 'from a %s source with selectSeverityNumber=%p, selects SeverityNumber: %p', - (kind, selectSeverityNumber, expected) => { - testEventsAroundFocus({ - tableSource: kind === 'log' ? mockLogSource : mockTableSource, + it.each([true, false])( + 'selects SeverityNumber from a log source only with the flag (%p), never in row ids', + selectSeverityNumber => { + const getRowWhere = jest.fn(() => ({ where: 'row-id', aliasWith: [] })); + mockUseRowWhere.mockReturnValue(getRowWhere); + + const result = testEventsAroundFocus({ + tableSource: { ...mockTableSource, kind: SourceKind.Log } as TLogSource, selectSeverityNumber, + beforeData: { + data: [{ Body: 'log line', SeverityNumber: 17 }], + meta: [{ name: 'Body', type: 'String' }], + }, }); const [query] = mockUseOffsetPaginatedQuery.mock.calls[0]; - expect(selectsSeverityNumber(query)).toBe(expected); + expect(selectsSeverityNumber(query)).toBe(selectSeverityNumber); + expect(getRowWhere).toHaveBeenCalledWith({ Body: 'log line' }); + expect(result.rows[0]).toMatchObject({ SeverityNumber: 17 }); }, ); - it('leaves SeverityNumber out of row ids but keeps it on the row', () => { - const getRowWhere = jest.fn(() => ({ where: 'row-id', aliasWith: [] })); - mockUseRowWhere.mockReturnValue(getRowWhere); - - const result = testEventsAroundFocus({ - tableSource: mockLogSource, - selectSeverityNumber: true, - beforeData: { - data: [{ Body: 'log line', SeverityNumber: 17 }], - meta: [{ name: 'Body', type: 'String' }], - }, - }); - - expect(getRowWhere).toHaveBeenCalledWith({ Body: 'log line' }); - expect(result.rows[0]).toMatchObject({ id: 'row-id', SeverityNumber: 17 }); - }); - it('fetches events before and after focus date', () => { const mockBeforeData = { data: [{ Body: 'before focus' }], diff --git a/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx b/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx index fc76f5fbd7..4b037cdd1b 100644 --- a/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx +++ b/packages/app/src/components/__tests__/NetworkPropertyPanel.test.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { notifications } from '@mantine/notifications'; -import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { NetworkPropertySubpanel } from '@/components/NetworkPropertyPanel'; import { copyTextToClipboard } from '@/utils/clipboard'; +import type { Attributes } from '@/utils/spanAttributeReaders'; jest.mock('@/utils/clipboard', () => ({ - __esModule: true, CLIPBOARD_ERROR_MESSAGE: 'clipboard error', copyTextToClipboard: jest.fn(), })); @@ -27,208 +27,170 @@ jest.mock('@clickhouse/click-ui', () => ({ })); const mockCopy = jest.mocked(copyTextToClipboard); -const mockShowNotification = jest.mocked(notifications.show); -const CLIENT_SPAN = { +const HTTP_CLIENT_SPAN = { 'url.scheme': 'https', 'server.address': 'example-api.clickhouse.com', 'url.path': '/users', - 'url.query': 'id=1', 'http.request.method': 'POST', 'http.response.status_code': 503, - 'network.peer.address': '10.0.0.1', + 'error.type': '503', 'http.request.header.x_forwarded_for': '1.2.3.4', 'http.request.header.traceparent': '["00-0d98-cef1-01"]', 'http.request.body.content': '{"name":"a"}', - 'http.response.header.content-type': '["application/json"]', - 'http.response.body.content': '{"error":"down"}', + 'http.response.body.content': 'a'.repeat(8192), + 'http.response.body.size': '23817', }; -const renderPanel = (eventAttributes: Record) => +const GRPC_CALL = { + 'rpc.method': 'GetQuote', + 'rpc.service': 'demo.QuoteService', + 'rpc.system': 'grpc', + 'server.address': '127.0.0.1', + 'server.port': '3002', +}; + +const renderPanel = (eventAttributes: Attributes, spanKind?: string) => renderWithMantine( - , + , ); - const getBadge = (text: string) => screen.getByText(text, { selector: '[data-testid="badge"]' }); - -function getDetail(label: string) { - const row = screen.getByText(label).closest('tr'); - if (row == null) throw new Error(`No row for ${label}`); - return within(row).getAllByRole('cell')[1]; -} +const getRow = (label: string) => screen.getByText(label).closest('tr'); +const copyButton = () => screen.queryByRole('button', { name: 'Copy as cURL' }); describe('NetworkPropertySubpanel', () => { - beforeEach(() => { - mockCopy.mockReset().mockResolvedValue(true); - mockShowNotification.mockReset(); - }); - - it('renders the method, URL and status on one request line', () => { - renderPanel(CLIENT_SPAN); - - expect(getBadge('POST')).toHaveAttribute('data-state', 'neutral'); - expect( - screen.getByText('https://example-api.clickhouse.com/users?id=1'), - ).toBeInTheDocument(); - expect(getBadge('503 Service Unavailable')).toBeInTheDocument(); - }); - - it.each([ - ['200', '200 OK', 'success'], - ['299', '299', 'success'], - ['304', '304 Not Modified', 'info'], - ['404', '404 Not Found', 'warning'], - ['503', '503 Service Unavailable', 'danger'], - // The HyperDX browser SDK reports 0 for a request that got no response. - ['0', '0 No response', 'danger'], - ])('shows status %s as "%s" (%s)', (code, text, state) => { - renderPanel({ 'http.method': 'GET', 'http.status_code': code }); - expect(getBadge(text)).toHaveAttribute('data-state', state); - }); - - it('renders the details of a semconv server span', () => { - renderPanel({ - 'client.address': '::1', - 'http.request.method': 'GET', - 'http.response.status_code': '200', - 'http.route': '/api/hello', - 'network.peer.address': '::1', - 'network.peer.port': '49347', - 'network.protocol.version': '1.1', - 'server.address': 'localhost', - 'server.port': '3001', - 'url.path': '/api/hello', - 'url.scheme': 'http', - 'user_agent.original': 'Mozilla/5.0 Chrome/155.0.0.0', + beforeEach(() => jest.clearAllMocks()); + + describe('HTTP request', () => { + it('renders the request line, headers and bodies of a client span', () => { + renderPanel(HTTP_CLIENT_SPAN); + + expect(getBadge('POST')).toBeInTheDocument(); + expect( + screen.getByText('https://example-api.clickhouse.com/users'), + ).toBeInTheDocument(); + expect(getBadge('503 Service Unavailable').dataset.state).toBe('danger'); + // Instrumentations set error.type to the status code the badge shows. + expect(screen.queryByText('Error type')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Request headers (2)')); + expect(screen.getByText('X-Forwarded-For')).toHaveAttribute( + 'title', + 'http.request.header.x_forwarded_for', + ); + expect( + screen.getByText('Response body (truncated: 8.0 KB of 23.3 KB)'), + ).toBeInTheDocument(); }); - expect(screen.getByText('http://localhost:3001/api/hello')).toBeVisible(); - expect(getDetail('Route')).toHaveTextContent('/api/hello'); - expect(getDetail('Protocol')).toHaveTextContent('HTTP/1.1'); - expect(getDetail('Remote address')).toHaveTextContent('[::1]:49347'); - expect(getDetail('User agent')).toHaveTextContent( - 'Mozilla/5.0 Chrome/155.0.0.0', - ); - // The client is the socket peer, already shown as the remote address. - expect(screen.queryByText('Client address')).not.toBeInTheDocument(); - }); - - it('shows sizes, resends and a synthetic user agent', () => { - renderPanel({ - ...CLIENT_SPAN, - 'http.request.body.size': '1536', - 'http.request.size': '1900', - 'http.response.body.size': '87', - 'http.request.resend_count': '2', - 'user_agent.original': 'Googlebot/2.1', - 'user_agent.synthetic.type': 'bot', + it('renders a hostless request that got no response', () => { + // The HyperDX browser SDK reports status 0 for a request with no response. + renderPanel({ + 'http.method': 'GET', + 'http.target': '/users', + 'http.status_code': '0', + }); + expect(screen.getByText('/users')).toBeInTheDocument(); + expect(getBadge('0 No response').dataset.state).toBe('danger'); + // curl needs an absolute URL. + expect(copyButton()).not.toBeInTheDocument(); }); - expect(getDetail('Request size')).toHaveTextContent( - '1.5 KB body · 1.9 KB total', - ); - expect(getDetail('Response size')).toHaveTextContent('87 B body'); - expect(getDetail('Resends')).toHaveTextContent('2'); - expect(getBadge('bot')).toHaveAttribute('data-state', 'neutral'); - }); - - it('shows the error type unless it repeats the status code', () => { - const { unmount } = renderPanel({ ...CLIENT_SPAN, 'error.type': '503' }); - expect(screen.queryByText('Error type')).not.toBeInTheDocument(); - unmount(); - - renderPanel({ ...CLIENT_SPAN, 'error.type': '_OTHER' }); - expect(getDetail('Error type')).toHaveTextContent('_OTHER'); - }); - - it('lists headers by dashed name with the raw key on hover', () => { - renderPanel(CLIENT_SPAN); - - expect(screen.getByText('Request body')).toBeInTheDocument(); - expect(screen.getByText('Response headers (1)')).toBeInTheDocument(); - expect(screen.getByText('Response body')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Request headers (2)')); - - expect(screen.getByText('X-Forwarded-For')).toHaveAttribute( - 'title', - 'http.request.header.x_forwarded_for', - ); - expect(screen.getByText('1.2.3.4')).toBeInTheDocument(); - }); - - it('flags a body cut short of its reported size', () => { - renderPanel({ - ...CLIENT_SPAN, - 'http.response.body.content': 'a'.repeat(8192), - 'http.response.body.size': '23817', + it('renders the details of a semconv server span', () => { + renderPanel({ + 'client.address': '::1', + 'error.type': 'timeout', + 'http.request.method': 'GET', + 'http.route': '/api/hello', + 'network.peer.address': '::1', + 'network.peer.port': '49347', + }); + + expect(getRow('Route')).toHaveTextContent('/api/hello'); + expect(getRow('Remote address')).toHaveTextContent('[::1]:49347'); + expect(getRow('Error type')).toHaveTextContent('timeout'); + // The client is the socket peer, already shown as the remote address. + expect(screen.queryByText('Client address')).not.toBeInTheDocument(); }); - expect( - screen.getByText('Response body (truncated: 8.0 KB of 23.3 KB)'), - ).toBeInTheDocument(); + it.each([ + [true, { color: 'green', message: 'cURL command copied to clipboard' }], + [false, { color: 'red', message: 'clipboard error' }], + ])('copies a cURL command (copied: %s)', async (copied, notification) => { + mockCopy.mockResolvedValue(copied); + renderPanel(HTTP_CLIENT_SPAN); + fireEvent.click(screen.getByRole('button', { name: 'Copy as cURL' })); + + await waitFor(() => + expect(notifications.show).toHaveBeenCalledWith(notification), + ); + const curl = mockCopy.mock.calls[0][0]; + expect(curl).toContain('-X POST'); + expect(curl).toContain('-H "X-Forwarded-For: 1.2.3.4"'); + expect(curl).not.toContain('traceparent'); + expect(curl).toContain(`--data-raw '{"name":"a"}'`); + }); }); - it('renders a deprecated client span', () => { - renderPanel({ - 'http.url': 'https://old-example.clickhouse.com/a', - 'http.method': 'GET', - 'http.status_code': '404', - 'http.status_text': 'Page Gone', - 'net.peer.ip': '10.0.0.2', - 'net.peer.port': '8080', - 'http.flavor': '1.1', - 'http.user_agent': 'python-requests/2.32.3', + describe('RPC call', () => { + it('renders a gRPC error with its metadata and no cURL action', () => { + renderPanel({ + ...GRPC_CALL, + 'grpc.error_message': '5 NOT_FOUND: no quote for missing', + 'rpc.grpc.status_code': '5', + 'rpc.request.metadata.x_request_id': '["cb88b510"]', + 'rpc.response.metadata.x_quote_source': '["pricing-v2"]', + }); + + expect(getBadge('gRPC')).toBeInTheDocument(); + expect( + screen.getByText('demo.QuoteService/GetQuote'), + ).toBeInTheDocument(); + expect(getBadge('5 NOT_FOUND').dataset.state).toBe('danger'); + expect(getRow('Error message')).toHaveTextContent( + '5 NOT_FOUND: no quote for missing', + ); + expect(copyButton()).not.toBeInTheDocument(); + + expect(screen.getByText('Response metadata (1)')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Request metadata (1)')); + expect(screen.getByText('x-request-id')).toHaveAttribute( + 'title', + 'rpc.request.metadata.x_request_id', + ); }); - expect( - screen.getByText('https://old-example.clickhouse.com/a'), - ).toBeInTheDocument(); - expect(getBadge('404 Page Gone')).toHaveAttribute('data-state', 'warning'); - expect(getDetail('Remote address')).toHaveTextContent('10.0.0.2:8080'); - expect(getDetail('Protocol')).toHaveTextContent('HTTP/1.1'); - expect(getDetail('User agent')).toHaveTextContent('python-requests/2.32.3'); - }); - - it('copies a cURL command without the trace header', async () => { - renderPanel(CLIENT_SPAN); - fireEvent.click(screen.getByRole('button', { name: 'Copy as cURL' })); - - await waitFor(() => - expect(mockShowNotification).toHaveBeenCalledWith({ - color: 'green', - message: 'cURL command copied to clipboard', - }), - ); - const curl = mockCopy.mock.calls[0][0]; - expect(curl).toContain( - 'curl https://example-api.clickhouse.com/users?id=1', - ); - expect(curl).toContain('-X POST'); - expect(curl).toContain('-H "X-Forwarded-For: 1.2.3.4"'); - expect(curl).not.toContain('traceparent'); - expect(curl).toContain(`--data-raw '{"name":"a"}'`); - }); + it('puts the HTTP facts of gRPC over HTTP in one row', () => { + renderPanel({ + ...GRPC_CALL, + 'http.request.method': 'POST', + 'http.response.status_code': '200', + 'http.route': '/demo.QuoteService/GetQuote', + 'url.path': '/demo.QuoteService/GetQuote', + 'url.scheme': 'http', + }); + + expect(getRow('HTTP')).toHaveTextContent( + 'POST http://127.0.0.1:3002/demo.QuoteService/GetQuote · 200 OK', + ); + expect(screen.queryByText('Route')).not.toBeInTheDocument(); + }); - it('reports a clipboard failure', async () => { - mockCopy.mockResolvedValue(false); - renderPanel(CLIENT_SPAN); - fireEvent.click(screen.getByRole('button', { name: 'Copy as cURL' })); - - await waitFor(() => - expect(mockShowNotification).toHaveBeenCalledWith({ - color: 'red', - message: 'clipboard error', - }), - ); - }); + it('shows an "Error" badge for an error without a status code', () => { + renderPanel({ ...GRPC_CALL, 'grpc.error_message': 'card expired' }); + expect(getBadge('Error').dataset.state).toBe('danger'); + }); - it('hides the cURL action for a URL without a host', () => { - renderPanel({ 'http.method': 'GET', 'http.target': '/users' }); + it('reads the server from net.peer.name only on a client span', () => { + const call = { 'rpc.system': 'grpc', 'net.peer.name': 'checkout' }; + const { unmount } = renderPanel(call, 'Client'); + expect(getRow('Server')).toHaveTextContent('checkout'); + unmount(); - expect(screen.getByText('/users')).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Copy as cURL' }), - ).not.toBeInTheDocument(); + // On a server span net.peer.* is the caller. + renderPanel(call, 'Server'); + expect(screen.queryByText('Server')).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/app/src/components/__tests__/RpcCallPanel.test.tsx b/packages/app/src/components/__tests__/RpcCallPanel.test.tsx deleted file mode 100644 index cfeb3e3000..0000000000 --- a/packages/app/src/components/__tests__/RpcCallPanel.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import { fireEvent, screen, within } from '@testing-library/react'; - -import { NetworkPropertySubpanel } from '@/components/NetworkPropertyPanel'; - -// click-ui encodes the badge state only in hashed CSS module class names. -jest.mock('@clickhouse/click-ui', () => ({ - ...jest.requireActual('@clickhouse/click-ui'), - Badge: ({ text, state }: { text: React.ReactNode; state?: string }) => ( - - {text} - - ), -})); - -const GRPC_CALL = { - 'rpc.method': 'GetQuote', - 'rpc.service': 'demo.QuoteService', - 'rpc.system': 'grpc', - 'server.address': '127.0.0.1', - 'server.port': '3002', -}; - -const renderPanel = (eventAttributes: Record) => - renderWithMantine( - , - ); - -const getBadge = (text: string) => - screen.getByText(text, { selector: '[data-testid="badge"]' }); -const badgeTexts = () => - screen.queryAllByTestId('badge').map(badge => badge.textContent); - -function getDetail(label: string) { - const row = screen.getByText(label).closest('tr'); - if (row == null) throw new Error(`No row for ${label}`); - return within(row).getAllByRole('cell')[1]; -} - -const copyButton = () => screen.queryByRole('button', { name: 'Copy as cURL' }); - -describe('RPC call panel', () => { - it('renders a gRPC client error with its metadata', () => { - renderPanel({ - ...GRPC_CALL, - 'grpc.error_message': '5 NOT_FOUND: no quote for missing', - 'rpc.grpc.status_code': '5', - 'rpc.request.metadata.x_request_id': '["cb88b510"]', - 'rpc.response.metadata.x_quote_source': '["pricing-v2"]', - }); - - expect(getBadge('gRPC')).toHaveAttribute('data-state', 'neutral'); - expect(screen.getByText('demo.QuoteService/GetQuote')).toBeInTheDocument(); - expect(getBadge('5 NOT_FOUND')).toHaveAttribute('data-state', 'danger'); - expect(getDetail('Error message')).toHaveTextContent( - '5 NOT_FOUND: no quote for missing', - ); - expect(getDetail('Server')).toHaveTextContent('127.0.0.1:3002'); - expect(screen.queryByText('HTTP')).not.toBeInTheDocument(); - expect(copyButton()).not.toBeInTheDocument(); - - expect(screen.getByText('Response metadata (1)')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Request metadata (1)')); - expect(screen.getByText('x-request-id')).toHaveAttribute( - 'title', - 'rpc.request.metadata.x_request_id', - ); - expect(screen.getByText('cb88b510')).toBeInTheDocument(); - }); - - it('puts the HTTP facts of gRPC over HTTP in a details row', () => { - renderPanel({ - ...GRPC_CALL, - 'error.type': 'NOT_FOUND', - 'http.request.header.content-type': '["application/grpc"]', - 'http.request.method': 'POST', - 'http.response.status_code': '200', - 'http.route': '/demo.QuoteService/GetQuote', - 'network.protocol.version': '2', - 'rpc.grpc.status_code': '5', - 'url.path': '/demo.QuoteService/GetQuote', - 'url.scheme': 'http', - }); - - expect(badgeTexts()).toEqual(['gRPC', '5 NOT_FOUND']); - expect(getDetail('HTTP')).toHaveTextContent( - 'POST http://127.0.0.1:3002/demo.QuoteService/GetQuote · 200 OK', - ); - expect(getDetail('Protocol')).toHaveTextContent('HTTP/2'); - expect(screen.queryByText('Route')).not.toBeInTheDocument(); - expect(screen.queryByText('Error type')).not.toBeInTheDocument(); - expect(screen.getByText('Request headers (1)')).toBeInTheDocument(); - expect(copyButton()).not.toBeInTheDocument(); - }); - - it('reads the ASP.NET Core gRPC tags', () => { - renderPanel({ - 'grpc.method': '/demo.QuoteService/GetQuote', - 'grpc.status_code': '0', - 'http.request.method': 'POST', - }); - - expect(screen.getByText('demo.QuoteService/GetQuote')).toBeInTheDocument(); - expect(getBadge('0 OK')).toHaveAttribute('data-state', 'success'); - }); - - it('shows the system and status of another RPC system as emitted', () => { - renderPanel({ - 'rpc.method': 'acme.v1.UserService/GetUser', - 'rpc.status_code': 'not_found', - 'rpc.system': 'connect_rpc', - }); - - expect(badgeTexts()).toEqual(['connect_rpc', 'not_found']); - expect(getBadge('not_found')).toHaveAttribute('data-state', 'danger'); - }); - - it('shows an "Error" badge for an error without a status code', () => { - renderPanel({ ...GRPC_CALL, 'grpc.error_message': 'card expired' }); - - expect(getBadge('Error')).toHaveAttribute('data-state', 'danger'); - expect(getDetail('Error message')).toHaveTextContent('card expired'); - }); -}); diff --git a/packages/app/src/utils/__tests__/curlGenerator.test.ts b/packages/app/src/utils/__tests__/curlGenerator.test.ts index d6680817d5..cb6f566f22 100644 --- a/packages/app/src/utils/__tests__/curlGenerator.test.ts +++ b/packages/app/src/utils/__tests__/curlGenerator.test.ts @@ -2,37 +2,25 @@ import { CurlGenerator } from '@/utils/curlGenerator'; const url = 'https://example.com'; -describe('CurlGenerator', () => { - it.each([ - ['post', '-X POST'], - ['PROPFIND', '-X PROPFIND'], - ['GET;rm', "-X 'GET;RM'"], - ])('emits the method %s as %s', (method, expected) => { - expect(CurlGenerator({ url, method })).toContain(expected); - }); - - it('omits -X without a method', () => { - expect(CurlGenerator({ url })).not.toContain('-X'); - }); +it.each([ + ['propfind', '-X PROPFIND'], + ['GET;rm', "-X 'GET;RM'"], +])('CurlGenerator sends the method %p as %s', (method, expected) => { + expect(CurlGenerator({ url, method })).toContain(expected); +}); - it('emits one -H per header, including repeated names', () => { - const curl = CurlGenerator({ - url, - headers: [ - ['set-cookie', 'a=1'], - ['set-cookie', 'b="2"'], - ], - }); - expect(curl).toContain('-H "set-cookie: a=1"'); - expect(curl).toContain('-H "set-cookie: b=\\"2\\""'); - }); +it('CurlGenerator omits -X without a method', () => { + expect(CurlGenerator({ url })).not.toContain('-X'); +}); - it('sends string bodies raw and object bodies as JSON', () => { - expect(CurlGenerator({ url, body: 'a=1&b=2' })).toContain( - "--data-raw 'a=1&b=2'", - ); - expect(CurlGenerator({ url, body: { a: 1 } })).toContain( - '-d "{\\"a\\":1}"', - ); +it('CurlGenerator keeps repeated header names', () => { + const curl = CurlGenerator({ + url, + headers: [ + ['set-cookie', 'a=1'], + ['set-cookie', 'b=2'], + ], }); + expect(curl).toContain('-H "set-cookie: a=1"'); + expect(curl).toContain('-H "set-cookie: b=2"'); }); diff --git a/packages/app/src/utils/__tests__/httpHeaders.test.ts b/packages/app/src/utils/__tests__/httpHeaders.test.ts index d88f080f5e..dd9845ff5d 100644 --- a/packages/app/src/utils/__tests__/httpHeaders.test.ts +++ b/packages/app/src/utils/__tests__/httpHeaders.test.ts @@ -6,98 +6,51 @@ import { toHeaderDisplayCase, } from '@/utils/httpHeaders'; -const parseRequestHeaders = (attributes: Record) => - parseHttpHeaders(attributes, HTTP_REQUEST_HEADER_PREFIX); - -describe('parseHttpHeaders', () => { - it('reads one prefix and shows Python underscore names with dashes', () => { - expect( - parseRequestHeaders({ - 'http.request.header.x_forwarded_for': '1.2.3.4', - 'http.response.header.content-type': 'text/html', - 'http.url': 'https://clickhouse.com', - }), - ).toEqual([ - { - name: 'x-forwarded-for', - value: '1.2.3.4', - attributeKey: 'http.request.header.x_forwarded_for', - }, - ]); - }); - - it.each([['a=1', 'b=2'], '["a=1","b=2"]'])( - 'expands the array value %p into one entry each', - value => { - expect( - parseRequestHeaders({ 'http.request.header.cookie': value }).map( - header => header.value, - ), - ).toEqual(['a=1', 'b=2']); +it('parseHttpHeaders reads one prefix, one entry per array value', () => { + const headers = parseHttpHeaders( + { + 'http.request.header.x_id': '["a","b"]', + 'http.request.header.etag': '"c"', + 'http.request.header.age': '0123', + 'http.response.header.server': 'd', }, + HTTP_REQUEST_HEADER_PREFIX, ); - - it('keeps values that are not JSON arrays verbatim', () => { - expect( - parseRequestHeaders({ - 'http.request.header.content-length': '0123', - 'http.request.header.if-none-match': '"abc"', - 'http.request.header.x-broken': '[not json', - 'http.request.header.x-count': 3, - 'http.request.header.x-object': { a: 1 }, - }).map(header => header.value), - ).toEqual(['0123', '"abc"', '[not json', '3', '{"a":1}']); - }); + expect(headers.map(({ name, value }) => `${name}: ${value}`)).toEqual([ + 'x-id: a', + 'x-id: b', + 'etag: "c"', + 'age: 0123', + ]); + expect(headers[0].attributeKey).toBe('http.request.header.x_id'); }); -describe('firstHttpHeaderValue', () => { - const userAgent = (attributes: Record) => - firstHttpHeaderValue(attributes, HTTP_REQUEST_HEADER_PREFIX, 'user-agent'); - - it('matches the name case-insensitively and takes the first value', () => { - expect(userAgent({ 'http.request.header.User_Agent': '["a","b"]' })).toBe( - 'a', - ); - }); - - it('skips empty values and other prefixes', () => { - expect( - userAgent({ +it('firstHttpHeaderValue ignores case and empty values', () => { + expect( + firstHttpHeaderValue( + { 'http.request.header.user-agent': '', - 'http.response.header.user-agent': 'a', - }), - ).toBeUndefined(); - }); + 'http.request.header.User_Agent': '["a","b"]', + }, + HTTP_REQUEST_HEADER_PREFIX, + 'user-agent', + ), + ).toBe('a'); }); -describe('getReplayableHeaders', () => { - it('drops trace context, Host, Content-Length and connection headers', () => { - const headers = parseRequestHeaders({ - 'http.request.header.traceparent': '["00-0d98-cef1-01"]', - 'http.request.header.Host': 'localhost:3001', - 'http.request.header.content-length': '["90"]', - 'http.request.header.content-type': '["application/json"]', - 'http.request.header.baggage': 'tenant=acme', - }); - expect(getReplayableHeaders(headers).map(header => header.name)).toEqual([ - 'content-type', - 'baggage', - ]); +it('toHeaderDisplayCase capitalizes each word of the name', () => { + const header = { name: 'x-forwarded-for', value: 'a', attributeKey: 'k' }; + expect(toHeaderDisplayCase(header)).toEqual({ + ...header, + name: 'X-Forwarded-For', }); }); -describe('toHeaderDisplayCase', () => { - it('capitalizes each dash-separated word and keeps the attribute key', () => { - expect( - toHeaderDisplayCase({ - name: 'x-forwarded-for', - value: '1.2.3.4', - attributeKey: 'http.request.header.x_forwarded_for', - }), - ).toEqual({ - name: 'X-Forwarded-For', - value: '1.2.3.4', - attributeKey: 'http.request.header.x_forwarded_for', - }); - }); +it('getReplayableHeaders drops trace context, Host and Content-Length', () => { + const headers = ['traceparent', 'Host', 'content-length', 'baggage'].map( + name => ({ name, value: 'a', attributeKey: name }), + ); + expect(getReplayableHeaders(headers).map(({ name }) => name)).toEqual([ + 'baggage', + ]); }); diff --git a/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts b/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts index 54dfa5e1b3..4577f3b00c 100644 --- a/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts +++ b/packages/app/src/utils/__tests__/httpSpanAttributes.test.ts @@ -12,147 +12,81 @@ import { isHttpSpan, } from '@/utils/httpSpanAttributes'; -describe('isHttpSpan', () => { - it.each([ - [{ 'http.response.status_code': 0 }, true], - [{ 'http.target': '/a?b=1' }, true], - [{ 'url.full': '', 'http.request.method': null }, false], - [{ 'db.system': 'clickhouse' }, false], - ])('detects %o: %p', (attributes, expected) => { - expect(isHttpSpan(attributes)).toBe(expected); - }); +it.each([ + [isHttpSpan, { 'http.response.status_code': 0 }, true], + [isHttpSpan, { 'http.target': '/a' }, true], + [isHttpSpan, { 'url.full': '' }, false], + [getHttpMethod, { 'http.request.method': '', 'http.method': 'GET' }, 'GET'], + [ + getHttpMethod, + { 'http.request.method': '_OTHER', 'http.request.method_original': 'X' }, + 'X', + ], + [getHttpStatusCode, { 'http.status_code': '500' }, 500], + [getHttpRoute, { 'url.template': '/u/{id}' }, '/u/{id}'], + [getHttpProtocol, { 'http.flavor': '2.0' }, 'HTTP/2'], + [getHttpProtocol, { 'http.flavor': '1.0' }, 'HTTP/1.0'], + [ + getHttpProtocol, + { 'network.protocol.name': 'spdy', 'network.protocol.version': '3' }, + 'SPDY/3', + ], + [getHttpProtocol, { 'http.flavor': 'SPDY' }, 'SPDY'], + [getUserAgent, { 'user_agent.original': 'a', 'rpc.user_agent': 'b' }, 'a'], + [getUserAgent, { 'rpc.user_agent': 'b', 'http.user_agent': 'c' }, 'b'], + [ + getUserAgent, + { 'http.user_agent': 'c', 'http.request.header.user-agent': 'd' }, + 'c', + ], + [getUserAgent, { 'http.request.header.user_agent': '["d"]' }, 'd'], + [ + getHttpResendCount, + { 'http.request.resend_count': 2, 'http.resend_count': 3 }, + 2, + ], + [getHttpResendCount, { 'http.resend_count': 3, 'http.retry_count': 4 }, 3], + [getHttpResendCount, { 'http.retry_count': '4' }, 4], +])('%p(%o) is %p', (getter, attributes, expected) => { + expect(getter(attributes)).toBe(expected); }); -describe('getHttpMethod', () => { - it.each([ - [{ 'http.request.method': 'POST', 'http.method': 'GET' }, 'POST'], - [{ 'http.request.method': '', 'http.method': 'GET' }, 'GET'], - [ - { - 'http.request.method': '_OTHER', - 'http.request.method_original': 'PROPFIND', - }, - 'PROPFIND', - ], - [{ 'http.request.method': '_OTHER' }, '_OTHER'], - [{}, undefined], - ])('reads %o as %p', (attributes, expected) => { - expect(getHttpMethod(attributes)).toBe(expected); - }); +it.each([ + [{ 'http.status_text': 'Nope' }, 404, 'Nope'], + [{}, 404, 'Not Found'], + [{}, 418, undefined], +])('getHttpStatusText(%o, %p) is %p', (attributes, code, expected) => { + expect(getHttpStatusText(attributes, code)).toBe(expected); }); -describe('getHttpStatusCode', () => { - it.each([ - [{ 'http.response.status_code': 404, 'http.status_code': 200 }, 404], - [{ 'http.response.status_code': '', 'http.status_code': '500' }, 500], - [{ 'http.status_code': '0' }, 0], - [{ 'http.response.status_code': 'abc' }, undefined], - ])('reads %o as %p', (attributes, expected) => { - expect(getHttpStatusCode(attributes)).toBe(expected); - }); - - it.each([ - [{ 'http.status_text': 'Nope' }, 404, 'Nope'], - [{}, 404, 'Not Found'], - [{}, 418, undefined], - [{}, undefined, undefined], - ])('reads the status text of %o and %p', (attributes, code, expected) => { - expect(getHttpStatusText(attributes, code)).toBe(expected); - }); -}); - -describe('getHttpRoute', () => { - it('prefers http.route over url.template', () => { - expect(getHttpRoute({ 'http.route': '/u/:id', 'url.template': 'x' })).toBe( - '/u/:id', - ); - expect(getHttpRoute({ 'url.template': '/u/{id}' })).toBe('/u/{id}'); - }); -}); - -describe('getHttpProtocol', () => { - it.each([ - [{ 'network.protocol.version': '2', 'http.flavor': '1.0' }, 'HTTP/2'], - [{ 'net.protocol.name': 'spdy', 'net.protocol.version': '3' }, 'SPDY/3'], - [{ 'http.flavor': '3.0' }, 'HTTP/3'], - [{ 'http.flavor': 'QUIC' }, 'QUIC'], - [{ 'network.protocol.name': 'http' }, undefined], - ])('formats %o as %p', (attributes, expected) => { - expect(getHttpProtocol(attributes)).toBe(expected); - }); -}); - -describe('getUserAgent', () => { - it.each([ - [{ 'user_agent.original': 'a', 'rpc.user_agent': 'b' }, 'a'], - [{ 'rpc.user_agent': 'b', 'http.user_agent': 'c' }, 'b'], - [{ 'http.user_agent': 'c', 'http.request.header.user-agent': 'd' }, 'c'], - [{ 'http.request.header.user_agent': '["d"]' }, 'd'], - ])('reads %o as %p', (attributes, expected) => { - expect(getUserAgent(attributes)).toBe(expected); - }); -}); - -describe('getHttpMessageSize', () => { - it('reads the body size chain in order', () => { - const chain = [ - ['http.response.body.size', '10'], - ['http.response_content_length', '20'], - ['http.response.header.content-length', '30'], - ['http.response_content_length_uncompressed', '40'], - ]; - const attributes = Object.fromEntries(chain); - for (const [key, size] of chain) { - expect(getHttpMessageSize(attributes, 'response')).toBe(`${size} B body`); - delete attributes[key]; - } - expect(getHttpMessageSize(attributes, 'response')).toBeUndefined(); - }); - - it('shows the body and total sizes', () => { - expect( - getHttpMessageSize( - { 'http.request.body.size': '1536', 'http.request.size': '1946' }, - 'request', - ), - ).toBe('1.5 KB body · 1.9 KB total'); - expect(getHttpMessageSize({ 'http.request.size': 0 }, 'request')).toBe( - '0 B total', - ); - }); -}); - -describe('getHttpResendCount', () => { - it.each([ - [{ 'http.request.resend_count': '2', 'http.resend_count': '3' }, 2], - [{ 'http.resend_count': '3', 'http.retry_count': '4' }, 3], - [{ 'http.retry_count': 4 }, 4], - ])('reads %o as %p', (attributes, expected) => { - expect(getHttpResendCount(attributes)).toBe(expected); - }); -}); - -describe('getHttpBody', () => { - it.each([ - [{ 'http.request.body.content': 'new', 'http.request.body': 'old' }, 'new'], - [{ 'http.request.body': 'old' }, 'old'], - [{ 'http.request.body.content': '', 'http.request.body': 'old' }, ''], - [{ 'http.response.body': 'old' }, undefined], - ])('reads the request body of %o as %p', (attributes, expected) => { - expect(getHttpBody(attributes, 'request')).toBe(expected); - }); - - it.each([ - ['a cut body', '23817', 'a'.repeat(8192), '8.0 KB of 23.3 KB'], - ['UTF-8 bytes, not characters', '4', 'éé', undefined], - ['a non-string body', '30', { a: 1 }, undefined], - ['no reported size', undefined, 'abc', undefined], - ])('reports truncation for %s', (_case, size, body, expected) => { - expect( - getHttpBodyTruncation( - { 'http.response.body.size': size, 'http.response.body.content': body }, - 'response', - ), - ).toBe(expected); - }); +it.each([ + [ + getHttpMessageSize, + { 'http.request.body.size': 1536, 'http.request.size': 1946 }, + '1.5 KB body · 1.9 KB total', + ], + [ + getHttpMessageSize, + { 'http.request.header.content-length': '["30"]' }, + '30 B body', + ], + [ + getHttpBody, + { 'http.request.body.content': '', 'http.request.body': 'a' }, + '', + ], + [getHttpBody, { 'http.request.body': 'a' }, 'a'], + [ + getHttpBodyTruncation, + { 'http.request.body.content': 'abc', 'http.request.body.size': 10 }, + '3 B of 10 B', + ], + // Bytes, not characters. + [ + getHttpBodyTruncation, + { 'http.request.body.content': 'éé', 'http.request.body.size': 4 }, + undefined, + ], +])('%p(%o, "request") is %p', (getter, attributes, expected) => { + expect(getter(attributes, 'request')).toBe(expected); }); diff --git a/packages/app/src/utils/__tests__/httpUrl.test.ts b/packages/app/src/utils/__tests__/httpUrl.test.ts index 5c9c1eacd0..9413c10471 100644 --- a/packages/app/src/utils/__tests__/httpUrl.test.ts +++ b/packages/app/src/utils/__tests__/httpUrl.test.ts @@ -1,120 +1,51 @@ -import { getHttpUrl } from '@/utils/httpUrl'; +import { getHttpUrl, isAbsoluteHttpUrl } from '@/utils/httpUrl'; -describe('getHttpUrl', () => { - const stable = { - 'url.scheme': 'https', - 'server.address': 'new-example.clickhouse.com', - 'url.path': '/a', - }; - - it.each([ - [ - 'url.full over everything else', - { ...stable, 'url.full': 'https://full-example.clickhouse.com/a' }, - 'https://full-example.clickhouse.com/a', - ], - [ - 'an absolute stable rebuild over http.url', - { - ...stable, - 'url.query': '?b=1', - 'http.url': 'https://old-example.clickhouse.com', - }, - 'https://new-example.clickhouse.com/a?b=1', - ], - [ - 'http.url over a relative stable rebuild', - { 'url.scheme': 'http', 'url.path': '/a', 'http.url': 'http://e.com/a' }, - 'http://e.com/a', - ], - [ - 'http.url when the other values are empty', - { 'url.full': '', 'url.path': '', 'http.url': 'http://e.com/a' }, - 'http://e.com/a', - ], - [ - 'the path and query without a host', - { 'url.path': '/users', 'url.query': 'id=1' }, - '/users?id=1', - ], - ['nothing without a URL attribute', { 'http.method': 'GET' }, undefined], - ])('returns %s', (_case, attributes, expected) => { - expect(getHttpUrl(attributes)).toBe(expected); - }); - - it.each([ - [ - 'HTTPS', - 'api-example.clickhouse.com', - 443, - 'HTTPS://api-example.clickhouse.com/a', - ], - ['http', 'localhost', 8080, 'http://localhost:8080/a'], - ['http', '::1', 8080, 'http://[::1]:8080/a'], - ['http', '[::1]', undefined, 'http://[::1]/a'], - ['http', 'localhost:5000', 5000, 'http://localhost:5000/a'], - ['http', '[::1]:8000', 8000, 'http://[::1]:8000/a'], - ])('joins %s, %s and port %p', (scheme, address, port, expected) => { - expect( - getHttpUrl({ - 'url.scheme': scheme, - 'server.address': address, - 'server.port': port, - 'url.path': '/a', - }), - ).toBe(expected); - }); +const server = (address: string, port?: number) => ({ + 'url.scheme': 'http', + 'server.address': address, + 'server.port': port, + 'url.path': '/a', +}); +const target = { 'http.scheme': 'http', 'http.target': '/a' }; - describe('pre-1.21 spans', () => { - it.each([ - [{ 'http.host': 'h.io:81', 'net.host.name': 'x' }, 'http://h.io:81/a'], - [{ 'net.host.name': 'h.io', 'net.host.port': 81 }, 'http://h.io:81/a'], - [{ 'http.server_name': 'h.io', 'net.host.port': '80' }, 'http://h.io/a'], - [{ 'net.peer.name': 'h.io', 'net.peer.port': 81 }, 'http://h.io:81/a'], - [{ 'http.host': 'h.io', 'net.peer.name': 'client' }, 'http://h.io/a'], - [{ 'server.address': 'h.io', 'http.host': 'old.io' }, 'http://h.io/a'], - ])('rebuilds from http.target and %o', (host, expected) => { - expect( - getHttpUrl({ 'http.scheme': 'http', 'http.target': '/a', ...host }), - ).toBe(expected); - }); +it.each([ + [{ ...server('h.io'), 'url.full': 'https://f.io' }, 'https://f.io'], + [ + { ...server('h.io', 80), 'url.query': '?b=1', 'http.url': 'http://u.io' }, + 'http://h.io/a?b=1', + ], + [{ 'url.path': '/a', 'http.url': 'http://u.io/a' }, 'http://u.io/a'], + [server('localhost:5000', 5000), 'http://localhost:5000/a'], + [server('[::1]:8000', 8000), 'http://[::1]:8000/a'], + [server('::1', 8080), 'http://[::1]:8080/a'], + [{ ...target, 'http.host': 'h.io', 'net.peer.name': 'c' }, 'http://h.io/a'], + [ + { ...target, 'net.peer.name': 'h.io', 'net.peer.port': 81 }, + 'http://h.io:81/a', + ], + [ + { ...target, 'http.target': 'http://u.io/a', 'http.host': 'p' }, + 'http://u.io/a', + ], + // OTel JS's Node http client sends no http.scheme. + [ + { + 'http.url': 'https://h.io/a', + 'http.target': '/a', + 'http.host': 'h.io:443', + }, + 'https://h.io/a', + ], + // ASGI's http.target drops the query. + [ + { ...target, 'http.host': 'h.io', 'http.url': 'http://h.io/a?q=1' }, + 'http://h.io/a?q=1', + ], +])('getHttpUrl(%o) is %p', (attributes, expected) => { + expect(getHttpUrl(attributes)).toBe(expected); +}); - it.each([ - [ - 'http.url over the parts of an OTel JS client span without a scheme', - { - 'http.url': 'https://api-example.clickhouse.com/v1/users?x=1', - 'http.target': '/v1/users?x=1', - 'http.host': 'api-example.clickhouse.com:443', - }, - 'https://api-example.clickhouse.com/v1/users?x=1', - ], - [ - 'http.url over an ASGI http.target without the query', - { - 'http.scheme': 'http', - 'http.host': 'localhost:8000', - 'http.target': '/items', - 'http.url': 'http://localhost:8000/items?q=shoes', - }, - 'http://localhost:8000/items?q=shoes', - ], - [ - 'url.path and url.query over http.target', - { 'url.path': '/new', 'url.query': 'a=1', 'http.target': '/old?b=2' }, - '/new?a=1', - ], - [ - 'an absolute-form http.target as is', - { - 'http.host': 'proxy:3128', - 'http.target': 'http://up-example.clickhouse.com/a', - }, - 'http://up-example.clickhouse.com/a', - ], - ['http.target alone without a host', { 'http.target': '/a' }, '/a'], - ])('returns %s', (_case, attributes, expected) => { - expect(getHttpUrl(attributes)).toBe(expected); - }); - }); +it('isAbsoluteHttpUrl needs an http(s) scheme', () => { + expect(isAbsoluteHttpUrl('HTTPS://h.io')).toBe(true); + expect(isAbsoluteHttpUrl('h.io:443/a')).toBe(false); }); diff --git a/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts b/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts index 2234e2df72..0254a679eb 100644 --- a/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts +++ b/packages/app/src/utils/__tests__/rpcSpanAttributes.test.ts @@ -7,114 +7,53 @@ import { getRpcSystemLabel, } from '@/utils/rpcSpanAttributes'; -describe('getRequestPanelTitle', () => { - it.each([ - [{ 'rpc.system': 'grpc', 'http.request.method': 'POST' }, 'gRPC Call'], - [{ 'rpc.grpc.status_code': 0 }, 'gRPC Call'], - [{ 'rpc.system.name': 'connect_rpc' }, 'RPC Call'], - [{ 'rpc.service': 'pkg.Service' }, 'RPC Call'], - [{ 'grpc.method': '', 'http.request.method': 'GET' }, 'HTTP Request'], - [{ 'db.system': 'clickhouse' }, undefined], - ])('titles %o as %p', (attributes, expected) => { - expect(getRequestPanelTitle(attributes)).toBe(expected); - }); - - it.each([ - [{ 'grpc.method': '/a.B/C' }, 'gRPC'], - [{ 'rpc.system': 'aws-api' }, 'aws-api'], - [{ 'rpc.method': 'a.B/C' }, 'RPC'], - ])('labels the system of %o as %p', (attributes, expected) => { - expect(getRpcSystemLabel(attributes)).toBe(expected); - }); +it.each([ + [getRequestPanelTitle, { 'rpc.system': 'grpc' }, 'gRPC Call'], + [getRequestPanelTitle, { 'grpc.method': 'x', 'url.full': 'x' }, 'gRPC Call'], + [getRequestPanelTitle, { 'rpc.method': 'x', 'url.full': 'x' }, 'RPC Call'], + [getRequestPanelTitle, { 'url.full': 'x' }, 'HTTP Request'], + [getRequestPanelTitle, { 'db.system': 'x' }, undefined], + [getRpcSystemLabel, { 'grpc.method': 'x' }, 'gRPC'], + [getRpcSystemLabel, { 'rpc.system': 'aws-api' }, 'aws-api'], + [getRpcSystemLabel, { 'rpc.method': 'x' }, 'RPC'], + [getRpcMethod, { 'rpc.service': 'a.B', 'rpc.method': 'C' }, 'a.B/C'], + [getRpcMethod, { 'rpc.method': '_OTHER', 'rpc.method_original': 'c' }, 'c'], + [getRpcMethod, { 'grpc.method': '/a.B/C' }, 'a.B/C'], + // grpc-dotnet reports a method the server doesn't know as `other`. + [getRpcMethod, { 'grpc.method': 'other', 'url.path': '/a.B/C' }, 'a.B/C'], +])('%p(%o) is %p', (getter, attributes, expected) => { + expect(getter(attributes)).toBe(expected); }); -describe('getRpcMethod', () => { - it.each([ - [{ 'rpc.method': 'a.B/C', 'grpc.method': '/x.Y/Z' }, 'a.B/C'], - [{ 'rpc.service': 'a.B', 'rpc.method': 'C' }, 'a.B/C'], - [{ 'rpc.method': '_OTHER', 'rpc.method_original': 'refund' }, 'refund'], - [{ 'rpc.method': '_OTHER' }, '_OTHER'], - [{ 'grpc.method': '/a.B/C' }, 'a.B/C'], - [{ 'rpc.system': 'grpc', 'url.path': '/a.B/C' }, 'a.B/C'], - [{ 'rpc.system': 'connect_rpc', 'url.path': '/a.B/C' }, undefined], - [{ 'grpc.method': 'other', 'url.path': '/a.B/C' }, 'a.B/C'], - [{ 'grpc.method': 'other' }, 'other'], - ])('reads %o as %p', (attributes, expected) => { - expect(getRpcMethod(attributes)).toBe(expected); - }); +it.each([ + ['grpc', 0, '0 OK', true], + ['grpc', '14', '14 UNAVAILABLE', false], + ['grpc', 'not_found', '5 NOT_FOUND', false], + ['grpc', '99', '99', false], + ['jsonrpc', '5', '5', false], +])('getRpcStatus reads a %s status %p as %p', (system, value, label, isOk) => { + expect( + getRpcStatus({ 'rpc.system': system, 'rpc.status_code': value }), + ).toMatchObject({ label, isOk }); }); -describe('getRpcStatus', () => { - it.each([ - [{ 'rpc.status_code': '4', 'rpc.response.status_code': '0' }], - [{ 'rpc.response.status_code': '4', 'rpc.grpc.status_code': 0 }], - [{ 'rpc.grpc.status_code': 4, 'grpc.status_code': '0' }], - [{ 'grpc.status_code': '4', 'grpc.status': '0' }], - [{ 'grpc.status': '4' }], - ])('reads the newest status in %o', attributes => { - expect(getRpcStatus({ ...attributes, 'rpc.system': 'grpc' })?.label).toBe( - '4 DEADLINE_EXCEEDED', - ); - }); - - it.each([ - ['grpc', 0, '0 OK', true], - ['grpc', '14', '14 UNAVAILABLE', false], - ['grpc', 'not_found', '5 NOT_FOUND', false], - ['grpc', '99', '99', false], - ['jsonrpc', '5', '5', false], - ['onc_rpc', 'ok', 'ok', true], - ])('normalizes a %s status %p to %p', (system, value, label, isOk) => { - expect( - getRpcStatus({ 'rpc.system': system, 'rpc.status_code': value }), - ).toMatchObject({ label, isOk }); - }); - - it('returns undefined without a status', () => { - expect(getRpcStatus({ 'grpc.status_code': '' })).toBeUndefined(); - }); +it('getRpcServerAddress reads net.peer.* only on client spans', () => { + const attributes = { 'net.peer.name': 'a', 'net.peer.port': 1 }; + expect(getRpcServerAddress(attributes, 'Client')).toBe('a:1'); + expect(getRpcServerAddress(attributes, 'Server')).toBeUndefined(); + expect(getRpcServerAddress(attributes, 'SPAN_KIND_SERVER')).toBeUndefined(); }); -describe('getRpcServerAddress', () => { - it.each([ - [{ 'server.address': 'quote', 'server.port': '8080' }, 'quote:8080'], - [{ 'net.peer.name': 'checkout', 'net.peer.port': 8080 }, 'checkout:8080'], - [{ 'server.address': 'fe80::1', 'server.port': 50051 }, '[fe80::1]:50051'], - ])('reads %o as %p', (attributes, expected) => { - expect(getRpcServerAddress(attributes)).toBe(expected); - }); - - it('ignores net.peer.* on a server span, where it names the caller', () => { - const attributes = { 'net.peer.name': 'localhost', 'net.peer.port': 41622 }; - expect(getRpcServerAddress(attributes, 'Server')).toBeUndefined(); - expect(getRpcServerAddress(attributes, 'SPAN_KIND_SERVER')).toBeUndefined(); - }); -}); - -describe('getRpcMetadata', () => { - it('merges request metadata across prefixes and drops repeats', () => { - expect( - getRpcMetadata( - { - 'rpc.request.header.x-request-id': '["abc"]', - 'rpc.request.metadata.x_request_id': '["abc"]', - 'rpc.grpc.request.metadata.x-tenant': 'acme', - 'http.request.header.x-other': 'ignored', - }, - 'request', - ).map(({ name, value }) => `${name}: ${value}`), - ).toEqual(['x-request-id: abc', 'x-tenant: acme']); - }); - - it('includes trailers in the response metadata', () => { - expect( - getRpcMetadata( - { - 'rpc.response.metadata.server': 'envoy', - 'rpc.response.trailer.grpc-status': '["0"]', - }, - 'response', - ).map(({ name, value }) => `${name}: ${value}`), - ).toEqual(['server: envoy', 'grpc-status: 0']); - }); +it('getRpcMetadata merges prefixes without repeats and includes trailers', () => { + const attributes = { + 'rpc.request.header.x-id': '["a"]', + 'rpc.request.metadata.x_id': 'a', + 'rpc.grpc.request.metadata.x-tenant': 'b', + 'rpc.response.metadata.server': 'c', + 'rpc.response.trailer.grpc-status': '0', + }; + const names = (direction: 'request' | 'response') => + getRpcMetadata(attributes, direction).map(header => header.name); + expect(names('request')).toEqual(['x-id', 'x-tenant']); + expect(names('response')).toEqual(['server', 'grpc-status']); }); diff --git a/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts b/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts index f1f56e390f..26da1aa186 100644 --- a/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts +++ b/packages/app/src/utils/__tests__/spanAttributeReaders.test.ts @@ -1,83 +1,59 @@ import { + firstSetString, formatAddress, getClientAddress, getLocalAddress, getNetworkPeerAddress, + toFiniteNumber, } from '@/utils/spanAttributeReaders'; -describe('formatAddress', () => { - it.each([ - [{ address: '10.0.0.1' }, '10.0.0.1'], - [{ address: '10.0.0.1', port: '443' }, '10.0.0.1:443'], - [{ address: '2001:db8::1', port: '443' }, '[2001:db8::1]:443'], - [{ address: '[2001:db8::1]', port: '443' }, '[2001:db8::1]:443'], - [{ address: '/var/run/app.sock', port: '0' }, '/var/run/app.sock'], - ])('formats %o as %s', (address, expected) => { - expect(formatAddress(address)).toBe(expected); - }); +it('firstSetString skips empty strings', () => { + expect(firstSetString({ a: '', b: 0 }, ['a', 'b'])).toBe('0'); }); -describe('getNetworkPeerAddress', () => { - it.each([ - ['network.peer.address', 'network.peer.port'], - ['net.sock.peer.addr', 'net.sock.peer.port'], - ['net.peer.ip', 'net.peer.port'], - ])('reads %s with %s', (address, port) => { - expect(getNetworkPeerAddress({ [address]: '10.0.0.1', [port]: 80 })).toBe( - '10.0.0.1:80', - ); - }); +it.each([ + ['42', 42], + ['', undefined], + ['Infinity', undefined], +])('toFiniteNumber(%p) is %p', (value, expected) => { + expect(toFiniteNumber(value)).toBe(expected); +}); + +it.each([ + [{ address: '::1', port: '80' }, '[::1]:80'], + [{ address: '::1' }, '::1'], + [{ address: '/run/app.sock', port: '0' }, '/run/app.sock'], +])('formatAddress(%o) is %p', (address, expected) => { + expect(formatAddress(address)).toBe(expected); +}); - it('prefers the newest pair and never takes a port from another pair', () => { - expect( - getNetworkPeerAddress({ - 'network.peer.address': '10.0.0.1', - 'net.sock.peer.addr': '10.0.0.2', - 'net.sock.peer.port': '51514', - }), - ).toBe('10.0.0.1'); - }); +it.each([ + [getNetworkPeerAddress, 'network.peer.address', 'network.peer.port', 'a:1'], + [getNetworkPeerAddress, 'net.sock.peer.addr', 'net.sock.peer.port', 'a:1'], + [getNetworkPeerAddress, 'net.peer.ip', 'net.peer.port', 'a:1'], + [getLocalAddress, 'network.local.address', 'network.local.port', 'a:1'], + [getLocalAddress, 'net.sock.host.addr', 'net.sock.host.port', 'a:1'], + [getLocalAddress, 'net.host.ip', 'net.host.port', 'a'], +])('%p reads %s with %s as %p', (getter, address, port, expected) => { + expect(getter({ [address]: 'a', [port]: 1 })).toBe(expected); }); -describe('getLocalAddress', () => { - it.each([ - ['network.local.address', 'network.local.port', '10.0.0.1:80'], - ['net.sock.host.addr', 'net.sock.host.port', '10.0.0.1:80'], - ['net.host.ip', 'net.host.port', '10.0.0.1'], - ])('reads %s with %s', (address, port, expected) => { - expect(getLocalAddress({ [address]: '10.0.0.1', [port]: 80 })).toBe( - expected, - ); - }); +it('getNetworkPeerAddress never takes a port from another pair', () => { + expect( + getNetworkPeerAddress({ + 'network.peer.address': 'a', + 'net.sock.peer.addr': 'b', + 'net.sock.peer.port': 1, + }), + ).toBe('a'); }); -describe('getClientAddress', () => { - it.each([ - [ - 'client.address with its port', - { - 'client.address': '203.0.113.9', - 'client.port': '65123', - 'http.client_ip': '198.51.100.1', - }, - '203.0.113.9:65123', - ], - ['http.client_ip', { 'http.client_ip': '198.51.100.1' }, '198.51.100.1'], - [ - 'the client behind a proxy', - { 'client.address': '203.0.113.9', 'network.peer.address': '10.0.0.5' }, - '203.0.113.9', - ], - [ - 'nothing when the client is the peer, ignoring ports', - { - 'client.address': '::1', - 'network.peer.address': '::1', - 'network.peer.port': '49347', - }, - undefined, - ], - ])('returns %s', (_case, attributes, expected) => { - expect(getClientAddress(attributes)).toBe(expected); - }); +it('getClientAddress hides the client when it is the peer, ignoring ports', () => { + const peer = { 'network.peer.address': 'a', 'network.peer.port': 1 }; + expect( + getClientAddress({ ...peer, 'client.address': 'a', 'client.port': 9 }), + ).toBeUndefined(); + expect( + getClientAddress({ ...peer, 'client.address': 'c', 'client.port': 9 }), + ).toBe('c:9'); });