diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaa845..0a2c325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## 1.4.0 (2026-07-20) +* 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) +* Picking the Trace-to-logs data source seeds default settings (filter by trace ID on, span time shifts `-1h`/`1h`) so a fresh configuration generates a useful logs query instead of an empty one + ## 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..edb0788 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,35 @@ 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 [Google Cloud Logging](https://grafana.com/grafana/plugins/googlecloud-logging-datasource/) data source the span links to. Picking one seeds sensible defaults for the options below (filter by trace ID on, time shifts `-1h`/`1h`); you can change them afterwards. +- **Span start/end time shift**: widen the logs search window relative to the span, e.g. `-1h` and `1h`. With both empty the window is the span's own duration, which is often only milliseconds. +- **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. + +> **Note:** the generated logs query is built only from the enabled filters, matching tags, and the custom query. If all of them are off/empty, the link produces an **empty query** and the logs panel returns everything (or nothing) in the span's time window. Keep at least **Filter by trace ID** enabled (the default when configuring through the UI), and when provisioning, set `filterByTraceID: true` explicitly. + +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..5a80ed5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "googlecloud-trace-datasource", - "version": "1.3.5", + "version": "1.4.0", "description": "Backend Grafana plugin that enables visualization of GCP Cloud Trace traces and spans in Grafana.", "scripts": { "postinstall": "rm -rf node_modules/flatted/golang", @@ -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/QueryEditor.tsx b/src/QueryEditor.tsx index 0af267b..d641d4b 100644 --- a/src/QueryEditor.tsx +++ b/src/QueryEditor.tsx @@ -65,21 +65,22 @@ export function CloudTraceQueryEditor({ datasource, query, range, onChange, onRu return text; }; - // Initialization effect: validates the carried-over project against the - // datasource's actual filtered project list. The list is the source of - // truth; the regex filter alone is insufficient because "no filter" passes - // everything, so a stale projectId from a different datasource would slip - // through. When the project is invalid, reseat to the default (or first - // available) and auto-rerun so the user sees correct traces immediately. + // Initialization effect: a non-empty projectId is kept as-is — it may come + // from a deliberate source the project list can't vouch for, such as a + // logs-to-traces link from the Cloud Logging plugin pointing at a project + // these credentials can read traces in but cannot list (or one excluded by + // the project filter). A genuinely wrong project fails loudly with an API + // error naming it, which beats silently querying a different project and + // reporting "trace not found". Only an empty projectId is reseated to the + // default (or first available) project, with an auto-rerun. useEffect(() => { let cancelled = false; (async () => { const latestQuery = queryRef.current; const needsQueryText = latestQuery.queryText == null && defaultQuery.queryText; - const currentProjectId = latestQuery.projectId; - if (currentProjectId && currentProjectId.startsWith('$')) { + if (latestQuery.projectId) { if (!cancelled && needsQueryText) { onChange({ ...latestQuery, queryText: defaultQuery.queryText }); } @@ -89,49 +90,20 @@ export function CloudTraceQueryEditor({ datasource, query, range, onChange, onRu const defaultProject = await datasource.getDefaultProject(); if (cancelled) { return; } - let cachedList: string[] | null = null; - let isValid = false; - - if (!currentProjectId) { - isValid = false; - } else if (datasource.filterProjects([currentProjectId]).length === 0) { - isValid = false; - } else { - try { - cachedList = await datasource.getFilteredProjects(); - if (cancelled) { return; } - isValid = cachedList.includes(currentProjectId); - } catch { - // Transient API error — assume valid so we don't reset a saved - // project on a flaky network. The eventual query will surface - // any real access error. - isValid = true; - } - } - - if (isValid) { - if (!cancelled && needsQueryText) { - onChange({ ...latestQuery, queryText: defaultQuery.queryText }); - } - return; - } - - // Pick a replacement project. + // Pick a project for the empty query. let newProjectId = ''; if (defaultProject && datasource.filterProjects([defaultProject]).length > 0) { newProjectId = defaultProject; } else { - let list = cachedList; - if (list === null) { - try { - list = await datasource.getFilteredProjects(); - if (cancelled) { return; } - } catch { - list = null; + try { + const list = await datasource.getFilteredProjects(); + if (cancelled) { return; } + if (list.length > 0) { + newProjectId = list[0]; } - } - if (list && list.length > 0) { - newProjectId = list[0]; + } catch { + // Transient API error — leave the project empty; the picker still + // lets the user choose one manually. } } diff --git a/src/TraceToLogsSettings.test.tsx b/src/TraceToLogsSettings.test.tsx new file mode 100644 index 0000000..37d7e4f --- /dev/null +++ b/src/TraceToLogsSettings.test.tsx @@ -0,0 +1,182 @@ +/** + * 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'; + +interface PickerMockProps { + onChange: (ds: { uid: string }) => void; + filter?: (ds: { type: string }) => boolean; +} + +let mockPickerProps: PickerMockProps | undefined; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + DataSourcePicker: (props: PickerMockProps) => { + mockPickerProps = props; + return ; + }, +})); + +const makeProps = (tracesToLogsV2?: TraceToLogsOptionsV2): Props => + ({ options: { jsonData: { tracesToLogsV2 } }, onOptionsChange: jest.fn() } as unknown as Props); + +const lastJsonData = (props: Props): { tracesToLogs?: unknown; tracesToLogsV2: TraceToLogsOptionsV2 } => { + const calls = (props.onOptionsChange as jest.Mock).mock.calls; + return calls[calls.length - 1][0].jsonData; +}; + +const lastSettings = (props: Props): TraceToLogsOptionsV2 => lastJsonData(props).tracesToLogsV2; + +describe('TraceToLogsSettings', () => { + it('seeds trace ID filter and time shifts when a data source is first picked', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByText('datasource-picker')); + expect(lastSettings(props)).toEqual({ + datasourceUid: 'logs-uid', + filterByTraceID: true, + spanStartTimeShift: '-1h', + spanEndTimeShift: '1h', + }); + }); + + it('preserves explicit user choices when re-picking a data source', () => { + const props = makeProps({ + datasourceUid: 'old-uid', + filterByTraceID: false, + spanStartTimeShift: '', + spanEndTimeShift: '1h', + }); + render(); + fireEvent.click(screen.getByText('datasource-picker')); + expect(lastSettings(props)).toEqual({ + datasourceUid: 'logs-uid', + filterByTraceID: false, + spanStartTimeShift: '', + spanEndTimeShift: '1h', + }); + }); + + it('only offers Google Cloud Logging data sources', () => { + render(); + expect(mockPickerProps?.filter?.({ type: 'googlecloud-logging-datasource' })).toBe(true); + expect(mockPickerProps?.filter?.({ type: 'loki' })).toBe(false); + }); + + it('preserves existing settings when toggling filter by trace ID', () => { + const props = makeProps({ datasourceUid: 'logs-uid', spanStartTimeShift: '-5m' }); + render(); + fireEvent.click(screen.getByLabelText(/Filter by trace ID/)); + expect(lastSettings(props)).toEqual({ + datasourceUid: 'logs-uid', + spanStartTimeShift: '-5m', + filterByTraceID: true, + }); + }); + + it('toggles filter by span ID', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText(/Filter by span ID/)); + expect(lastSettings(props).filterBySpanID).toBe(true); + }); + + it('clears the legacy tracesToLogs key on write', () => { + const props = makeProps(); + (props.options.jsonData as { tracesToLogs?: unknown }).tracesToLogs = { datasourceUid: 'old' }; + render(); + fireEvent.click(screen.getByLabelText(/Filter by trace ID/)); + expect(lastJsonData(props).tracesToLogs).toBeUndefined(); + }); + + it('shows an error for an invalid time shift and none for valid ones', () => { + const props = makeProps({ 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({ 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(); + render(); + fireEvent.change(screen.getAllByPlaceholderText('0')[0], { target: { value: '-1h' } }); + expect(lastSettings(props).spanStartTimeShift).toBe('-1h'); + }); + + it('adds and removes tags', () => { + const props = makeProps(); + const { rerender } = render(); + fireEvent.click(screen.getByText('Add tag')); + expect(lastSettings(props).tags).toEqual([{ key: '' }]); + + // Re-render with the tag present and edit its key + props.options.jsonData.tracesToLogsV2 = { tags: [{ key: '' }] }; + rerender(); + fireEvent.change(screen.getByLabelText('Tag key 1'), { target: { value: 'service.name' } }); + expect(lastSettings(props).tags).toEqual([{ key: 'service.name' }]); + + fireEvent.click(screen.getByLabelText('Remove tag 1')); + expect(lastSettings(props).tags).toEqual([]); + }); + + it('defaults the custom query switch to off when provisioned settings omit customQuery', () => { + // Shape from the README provisioning example: no customQuery key + const props = makeProps({ datasourceUid: 'logs-uid', spanStartTimeShift: '-5m', filterByTraceID: true }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const { rerender } = render(); + const switchInput = screen.getByLabelText(/Use custom query/) as HTMLInputElement; + expect(switchInput.checked).toBe(false); + expect(screen.queryByLabelText('Query')).toBeNull(); + + fireEvent.click(switchInput); + // Re-render with the toggled value: with value={undefined} the switch would go + // uncontrolled-to-controlled here and React would warn + props.options.jsonData.tracesToLogsV2 = { ...props.options.jsonData.tracesToLogsV2, customQuery: true }; + rerender(); + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + expect(lastSettings(props)).toEqual({ + datasourceUid: 'logs-uid', + spanStartTimeShift: '-5m', + filterByTraceID: true, + customQuery: true, + }); + }); + + it('reveals the query field only when custom query is enabled and stores the query', () => { + const props = makeProps(); + const { rerender } = render(); + expect(screen.queryByLabelText('Query')).toBeNull(); + + fireEvent.click(screen.getByLabelText(/Use custom query/)); + expect(lastSettings(props).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(lastSettings(props).query).toBe('"${__span.traceId}"'); + }); +}); diff --git a/src/TraceToLogsSettings.tsx b/src/TraceToLogsSettings.tsx new file mode 100644 index 0000000..30f8030 --- /dev/null +++ b/src/TraceToLogsSettings.tsx @@ -0,0 +1,195 @@ +/** + * 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 ?? {}; + const tags = settings.tags ?? []; + + const update = (patch: Partial) => + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + // Retire the legacy V1 key so it never shadows tracesToLogsV2 + tracesToLogs: undefined, + tracesToLogsV2: { ...settings, ...patch }, + }, + }); + + const updateTag = (index: number, patch: Partial) => { + const next = [...tags]; + next[index] = { ...next[index], ...patch }; + update({ tags: next }); + }; + + return ( +
+ + ds.type === 'googlecloud-logging-datasource'} + noDefault={true} + width={40} + current={settings.datasourceUid ?? null} + onChange={(ds) => + update({ + datasourceUid: ds.uid, + // Seed defaults the first time a data source is picked. With + // everything unset, Grafana core generates an EMPTY logs query: + // its fallback default tag keys (cluster, pod, service.name, …) + // rarely exist on Cloud Trace spans, unlike Tempo's. Explicit + // user choices (including false/'') are preserved on re-pick. + filterByTraceID: settings.filterByTraceID ?? true, + spanStartTimeShift: settings.spanStartTimeShift ?? '-1h', + spanEndTimeShift: settings.spanEndTimeShift ?? '1h', + }) + } + 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 && ( + +