Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -83,4 +84,4 @@
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
}
}
2 changes: 2 additions & 0 deletions src/ConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CloudTraceOptions, DataSourceSecureJsonData>;
Expand Down Expand Up @@ -157,6 +158,7 @@ export class ConfigEditor extends PureComponent<Props> {
</>
) : null}
{defaultProject(this.props)}
<TraceToLogsSettings options={this.props.options} onOptionsChange={this.props.onOptionsChange} />
</>
);
}
Expand Down
64 changes: 18 additions & 46 deletions src/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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.
}
}

Expand Down
182 changes: 182 additions & 0 deletions src/TraceToLogsSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <button onClick={() => props.onChange({ uid: 'logs-uid' })}>datasource-picker</button>;
},
}));

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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...makeProps()} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
expect(screen.queryByText('Invalid interval (e.g. 5m, -30s, 1h)')).toBeNull();
});

it('writes time shift changes to jsonData', () => {
const props = makeProps();
render(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
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(<TraceToLogsSettings {...props} />);
const textarea = screen.getByRole('textbox', { name: /query/i });
fireEvent.change(textarea, { target: { value: '"${__span.traceId}"' } });
expect(lastSettings(props).query).toBe('"${__span.traceId}"');
});
});
Loading
Loading