From ff39290a4d1965463e84a00a77a31d2111368350 Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Sun, 19 Jul 2026 14:07:04 +0000 Subject: [PATCH 1/4] add traces to logs --- CHANGELOG.md | 3 + README.md | 27 +++++ package.json | 3 +- src/ConfigEditor.tsx | 2 + src/TraceToLogsSettings.test.tsx | 137 +++++++++++++++++++++++ src/TraceToLogsSettings.tsx | 180 +++++++++++++++++++++++++++++++ src/types.ts | 29 +++++ yarn.lock | 59 +++++++++- 8 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 src/TraceToLogsSettings.test.tsx create mode 100644 src/TraceToLogsSettings.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaa845..17a4044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +## Unreleased +* Add **Trace to logs** correlation: configure a logs data source (e.g. Google Cloud Logging), span time shifts, tags, and trace/span ID filters — or a custom query — in the data source settings, and spans in the trace view get a "Logs for this span" link (requires Grafana >= 11.2) + ## 1.3.5 (2026-07-14) * Expose span status in the trace view: populate `kind`, `statusCode`, and `statusMessage` data frame fields, derived from Cloud Trace span labels (`/error/message`, `/error/name`, and HTTP status code labels per OpenTelemetry HTTP semantics) * Mark failed spans with an `error: true` tag so they are highlighted with the error icon in the Grafana trace view diff --git a/README.md b/README.md index 4cece75..4898063 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,33 @@ Each line is treated as a [regular expression](https://developer.mozilla.org/en- You can combine multiple patterns (one per line) to match the union of all patterns. If a pattern contains invalid regex syntax, it is treated as a literal string match. +### Trace to logs + +You can navigate from a span in the trace view to logs relevant to that span, the same way as in trace data sources like Tempo. Requires Grafana 11.2 or later and a logs data source such as [Google Cloud Logging](https://grafana.com/grafana/plugins/googlecloud-logging-datasource/). + +In the data source settings, under **Trace to logs**: + +- **Data source**: the logs data source the span links to. +- **Span start/end time shift**: widen the logs search window relative to the span, e.g. `-1h` and `1h`. Defaults to the span's own time range. +- **Tags**: span tags to include in the logs query; the optional value renames the tag in the query. +- **Filter by trace ID / span ID**: restrict the logs query to the span's trace/span ID. +- **Use custom query**: write your own Cloud Logging query with the variables `${__span.traceId}`, `${__span.spanId}`, `${__span.tags.X}`, and `$__tags`, e.g. `trace="projects/my-project/traces/${__span.traceId}"`. + +With this configured, spans in Explore and dashboard trace panels show a **Logs for this span** button. + +Provisioning example: + +```yaml +jsonData: + tracesToLogsV2: + datasourceUid: my-cloud-logging-datasource-uid + spanStartTimeShift: "-5m" + spanEndTimeShift: "5m" + filterByTraceID: true +``` + +For the reverse direction (from a log entry to its trace), configure **Logs to traces** in the Google Cloud Logging data source settings. + ## Usage ### Grafana Explore diff --git a/package.json b/package.json index 84211e1..20bf87d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@swc/core": "^1.15.0", "@swc/helpers": "^0.5.0", "@swc/jest": "^0.2.0", + "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.3.0", "@types/jest": "^29.5.0", @@ -83,4 +84,4 @@ "react": "^18.3.0", "react-dom": "^18.3.0" } -} \ No newline at end of file +} diff --git a/src/ConfigEditor.tsx b/src/ConfigEditor.tsx index 2b79016..1de2d70 100644 --- a/src/ConfigEditor.tsx +++ b/src/ConfigEditor.tsx @@ -18,6 +18,7 @@ import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/da import { ConnectionConfig, GoogleAuthType } from '@grafana/google-sdk'; import { Field, Input, Label, SecretInput, Select, TextArea } from '@grafana/ui'; import React, { PureComponent } from 'react'; +import { TraceToLogsSettings } from './TraceToLogsSettings'; import { authTypes, CloudTraceOptions, DataSourceSecureJsonData } from './types'; export type Props = DataSourcePluginOptionsEditorProps; @@ -157,6 +158,7 @@ export class ConfigEditor extends PureComponent { ) : null} {defaultProject(this.props)} + ); } diff --git a/src/TraceToLogsSettings.test.tsx b/src/TraceToLogsSettings.test.tsx new file mode 100644 index 0000000..67a0d8b --- /dev/null +++ b/src/TraceToLogsSettings.test.tsx @@ -0,0 +1,137 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Props, TraceToLogsSettings } from './TraceToLogsSettings'; +import { TraceToLogsOptionsV2 } from './types'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + DataSourcePicker: (props: { onChange: (ds: { uid: string }) => void }) => ( + + ), +})); + +const makeProps = (tracesToLogsV2?: TraceToLogsOptionsV2): Props => { + const onOptionsChange = jest.fn(); + return { + options: { + id: 1, + uid: 'trace-ds', + orgId: 1, + name: 'Google Cloud Trace', + type: 'googlecloud-trace-datasource', + typeName: 'Google Cloud Trace', + typeLogoUrl: '', + access: 'proxy', + url: '', + user: '', + database: '', + basicAuth: false, + basicAuthUser: '', + isDefault: false, + jsonData: { tracesToLogsV2 } as Props['options']['jsonData'], + secureJsonFields: {}, + readOnly: false, + withCredentials: false, + }, + onOptionsChange, + } as unknown as Props; +}; + +const lastJsonData = (props: Props): { tracesToLogsV2: TraceToLogsOptionsV2 } => { + const calls = (props.onOptionsChange as jest.Mock).mock.calls; + return calls[calls.length - 1][0].jsonData; +}; + +describe('TraceToLogsSettings', () => { + it('writes datasourceUid when a data source is picked', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByText('datasource-picker')); + expect(lastJsonData(props).tracesToLogsV2).toEqual({ customQuery: false, datasourceUid: 'logs-uid' }); + }); + + it('preserves existing settings when toggling filter by trace ID', () => { + const props = makeProps({ customQuery: false, datasourceUid: 'logs-uid', spanStartTimeShift: '-5m' }); + render(); + fireEvent.click(screen.getByLabelText(/Filter by trace ID/)); + expect(lastJsonData(props).tracesToLogsV2).toEqual({ + customQuery: false, + datasourceUid: 'logs-uid', + spanStartTimeShift: '-5m', + filterByTraceID: true, + }); + }); + + it('toggles filter by span ID', () => { + const props = makeProps({ customQuery: false }); + render(); + fireEvent.click(screen.getByLabelText(/Filter by span ID/)); + expect(lastJsonData(props).tracesToLogsV2.filterBySpanID).toBe(true); + }); + + it('shows an error for an invalid time shift and none for valid ones', () => { + const props = makeProps({ customQuery: false, spanStartTimeShift: 'xyz', spanEndTimeShift: '-30s' }); + render(); + expect(screen.getAllByText('Invalid interval (e.g. 5m, -30s, 1h)')).toHaveLength(1); + }); + + it('accepts fractional intervals like 1.5h', () => { + const props = makeProps({ customQuery: false, spanStartTimeShift: '1.5h' }); + render(); + expect(screen.queryByText('Invalid interval (e.g. 5m, -30s, 1h)')).toBeNull(); + }); + + it('writes time shift changes to jsonData', () => { + const props = makeProps({ customQuery: false }); + render(); + fireEvent.change(screen.getAllByPlaceholderText('0')[0], { target: { value: '-1h' } }); + expect(lastJsonData(props).tracesToLogsV2.spanStartTimeShift).toBe('-1h'); + }); + + it('adds and removes tags', () => { + const props = makeProps({ customQuery: false }); + const { rerender } = render(); + fireEvent.click(screen.getByText('Add tag')); + expect(lastJsonData(props).tracesToLogsV2.tags).toEqual([{ key: '' }]); + + // Re-render with the tag present and edit its key + props.options.jsonData.tracesToLogsV2 = { customQuery: false, tags: [{ key: '' }] }; + rerender(); + fireEvent.change(screen.getByLabelText('Tag key 1'), { target: { value: 'service.name' } }); + expect(lastJsonData(props).tracesToLogsV2.tags).toEqual([{ key: 'service.name' }]); + + fireEvent.click(screen.getByLabelText('Remove tag 1')); + expect(lastJsonData(props).tracesToLogsV2.tags).toEqual([]); + }); + + it('reveals the query field only when custom query is enabled and stores the query', () => { + const props = makeProps({ customQuery: false }); + const { rerender } = render(); + expect(screen.queryByLabelText('Query')).toBeNull(); + + fireEvent.click(screen.getByLabelText(/Use custom query/)); + expect(lastJsonData(props).tracesToLogsV2.customQuery).toBe(true); + + props.options.jsonData.tracesToLogsV2 = { customQuery: true }; + rerender(); + const textarea = screen.getByRole('textbox', { name: /query/i }); + fireEvent.change(textarea, { target: { value: '"${__span.traceId}"' } }); + expect(lastJsonData(props).tracesToLogsV2.query).toBe('"${__span.traceId}"'); + }); +}); diff --git a/src/TraceToLogsSettings.tsx b/src/TraceToLogsSettings.tsx new file mode 100644 index 0000000..ea4f49e --- /dev/null +++ b/src/TraceToLogsSettings.tsx @@ -0,0 +1,180 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; +import { Button, Field, FieldSet, IconButton, InlineSwitch, Input, TextArea } from '@grafana/ui'; +import React from 'react'; +import { CloudTraceOptions, DataSourceSecureJsonData, TraceToLogsOptionsV2, TraceToLogsTag } from './types'; + +export type Props = DataSourcePluginOptionsEditorProps; + +/** + * Same interval rule Grafana core uses for span time-shift inputs; + * negative shifts (e.g. -1h) are allowed to widen the range backwards. + */ +const intervalRegex = /^-?\d+(?:\.\d+)?(ms|[Mwdhmsy])$/; +const isInvalidInterval = (value?: string) => !!value && !intervalRegex.test(value); + +/** + * Config section for trace-to-logs correlation. Writes Grafana core's + * TraceToLogsOptionsV2 shape to jsonData.tracesToLogsV2; Grafana's trace view + * reads it from there to render "Logs for this span" links. + */ +export function TraceToLogsSettings({ options, onOptionsChange }: Props) { + const settings: TraceToLogsOptionsV2 = options.jsonData.tracesToLogsV2 ?? { customQuery: false }; + const tags = settings.tags ?? []; + + const update = (patch: Partial) => + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + tracesToLogsV2: { ...settings, ...patch }, + }, + }); + + const updateTag = (index: number, patch: Partial) => { + const next = [...tags]; + next[index] = { ...next[index], ...patch }; + update({ tags: next }); + }; + + return ( +
+ + update({ datasourceUid: ds.uid })} + onClear={() => update({ datasourceUid: undefined })} + /> + + + ) => update({ spanStartTimeShift: e.target.value })} + /> + + + ) => update({ spanEndTimeShift: e.target.value })} + /> + + +
+ {tags.map((tag, i) => ( +
+ ) => updateTag(i, { key: e.target.value })} + /> + ) => + updateTag(i, { value: e.target.value || undefined }) + } + /> + update({ tags: tags.filter((_, j) => j !== i) })} + /> +
+ ))} + +
+
+ + ) => update({ filterByTraceID: e.currentTarget.checked })} + /> + + + ) => update({ filterBySpanID: e.currentTarget.checked })} + /> + + + ) => update({ customQuery: e.currentTarget.checked })} + /> + + {settings.customQuery && ( + +