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.7.0 (2026-07-20)
* Add **Logs to traces** correlation: select a Google Cloud Trace data source in the data source settings, and log entries carrying a trace ID get a "View trace" link in the log details. The picker is restricted to Google Cloud Trace data sources, since the link query uses Cloud Trace's query format. When a log entry's `trace` value is not in the canonical `projects/<project>/traces/<id>` form, the link uses the data source's default project, or is omitted when no project can be determined
* Support queries arriving via Grafana's trace-to-logs span links (`query` field): interpolate template variables and fall back to the default project when the span link carries no project ID. The fallback also covers GCE authentication (auto-detected project) and applies only to span-link queries, so dashboard queries with an empty project ID still surface an error

## 1.6.4 (2026-05-06)
* **Bump minimum Grafana version to 11.2.0.** Earlier versions have an upstream `<Select>` rendering bug ([#89530](https://github.com/grafana/grafana/issues/89530), fixed in 11.2.0) that can cause the project picker to visually display stale options after switching datasources in Explore.
* Fix stale project ID persisting in query editor when switching between datasources
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ Patterns are [regular expressions](https://developer.mozilla.org/en-US/docs/Web/

If a pattern contains invalid regex syntax, it is treated as a literal string match.

### Logs to traces

You can link log entries to a [Google Cloud Trace](https://grafana.com/grafana/plugins/googlecloud-trace-datasource/) data source. In the data source settings, under **Logs to traces**, select the Google Cloud Trace data source. Once configured, any log entry written with the [LogEntry `trace` field](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry) shows a `traceId` field with a **View trace** link in the log details — clicking it opens the trace in the selected data source. The link carries the project parsed from the entry's `projects/<project>/traces/<id>` path; if the `trace` value is not in that form, the data source's default project is used, and if no project can be determined the link is omitted.

Provisioning example:

```yaml
jsonData:
logsToTraces:
datasourceUid: my-cloud-trace-datasource-uid
```

> **Note: a "View trace" link is not a guarantee that the trace was recorded.** For services with automatic request tracing (Cloud Run, App Engine, GKE ingress, and other services behind Google's HTTP load balancing), every request log entry carries a trace ID, but Cloud Trace only stores traces for **sampled** requests — and the built-in sampling rate is low (roughly 0.1 traces per second per instance for Cloud Run). Clicking **View trace** for an unsampled request shows a "trace not found" result; this is expected and matches the behavior of the trace links in the Google Cloud console's Logs Explorer. To make more links resolve, increase sampling on the application side: instrument the service with [OpenTelemetry](https://cloud.google.com/trace/docs/setup) and configure your own sampling rate, or force sampling on individual requests by sending an `X-Cloud-Trace-Context: <trace-id>/<span-id>;o=1` header (or a W3C `traceparent` header with the sampled flag set). Note that Cloud Trace bills per ingested span, so consider cost before sampling at 100% on high-traffic services.

For the reverse direction (from a trace span to its logs), configure **Trace to logs** in the Google Cloud Trace data source settings.

### An alternative way to provision the data source

After the plugin is installed, you can define and configure the data source in YAML files as part of Grafana's provisioning system, similar to [the Google Cloud Monitoring plugin](https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/#provision-the-data-source). For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "googlecloud-logging-datasource",
"version": "1.6.4",
"version": "1.7.0",
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Logging logs in Grafana.",
"scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
Expand Down
9 changes: 8 additions & 1 deletion pkg/plugin/cloudlogging/cloudlogging.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,14 @@ func GetLogLabels(entry *loggingpb.LogEntry) data.Labels {
}
}

// Add trace data
// Add trace data.
// Contract: the frontend's logs-to-traces feature (src/datasource.ts,
// addTraceLinkField) depends on the `trace` and `traceId` label names and
// on `trace` carrying the raw LogEntry value in the canonical
// `projects/<project>/traces/<id>` form — it re-parses that path to
// extract the project for the "View trace" link. Renaming these labels or
// changing their format silently breaks that feature; no test crosses the
// Go/TS boundary.
traceId := entry.GetTrace()
spanId := entry.GetSpanId()
if traceId != "" {
Expand Down
37 changes: 36 additions & 1 deletion src/ConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
import { ConnectionConfig, GoogleAuthType } from '@grafana/google-sdk';
import { Checkbox, Field, Input, SecretInput, Select, TextArea } from '@grafana/ui';
import { DataSourcePicker } from '@grafana/runtime';
import { Checkbox, Field, FieldSet, Input, SecretInput, Select, TextArea } from '@grafana/ui';
import React, { PureComponent } from 'react';
import { authTypes, CloudLoggingOptions, DataSourceSecureJsonData } from './types';

Expand Down Expand Up @@ -162,11 +163,45 @@ export class ConfigEditor extends PureComponent<Props> {
</>
) : null}
{defaultProject(this.props)}
{logsToTraces(this.props)}
</>
);
}
}

const logsToTraces = (props: Props) => {
const { options, onOptionsChange } = props;
const setLogsToTraces = (uid?: string) =>
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
logsToTraces: uid ? { datasourceUid: uid } : undefined,
},
});
return (
<FieldSet label="Logs to traces">
<Field
label="Trace data source"
description="Log entries containing a trace ID get a 'View trace' link that opens the selected Google Cloud Trace data source."
htmlFor="logs-to-traces-datasource"
>
<DataSourcePicker
inputId="logs-to-traces-datasource"
// The link query built in datasource.ts uses Cloud Trace's query
// shape, so only that datasource type can consume it.
filter={(ds) => ds.type === 'googlecloud-trace-datasource'}
noDefault={true}
width={40}
current={options.jsonData.logsToTraces?.datasourceUid ?? null}
onChange={(ds) => setLogsToTraces(ds.uid)}
onClear={() => setLogsToTraces()}
/>
</Field>
</FieldSet>
);
};

const defaultProject = (props: Props) => {
const { options, onOptionsChange } = props;
return (
Expand Down
142 changes: 139 additions & 3 deletions src/datasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@
* limitations under the License.
*/

import { DataSourcePluginMeta } from '@grafana/data';
import { ArrayVector, DataFrame, DataSourcePluginMeta, FieldType, Labels, ScopedVars } from '@grafana/data';
import { DataSourceWithBackend, TemplateSrv } from '@grafana/runtime';
import { GoogleAuthType } from '@grafana/google-sdk';
import { random } from 'lodash';
import { lastValueFrom, of } from 'rxjs';
import { DataSource } from './datasource';
import { CloudLoggingOptions, Query } from './types';

jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: (uid?: string) =>
uid === 'trace-uid' ? { uid: 'trace-uid', name: 'Google Cloud Trace' } : undefined,
}),
}));


describe('Google Cloud Logging Data Source', () => {
Expand Down Expand Up @@ -230,9 +241,134 @@ describe('Google Cloud Logging Data Source', () => {
expect(ds.filterBuckets(allBuckets)).toEqual([]);
});
});

describe('logs to traces data links', () => {
const logFrame = (labels?: Labels): DataFrame => ({
name: 'insert-id-1',
refId: 'A',
length: 1,
fields: [
{ name: 'time', type: FieldType.time, config: {}, values: new ArrayVector([1700000000000]) },
{ name: 'content', type: FieldType.string, config: {}, labels, values: new ArrayVector(['hello']) },
],
});

const runQuery = async (ds: DataSource, frame: DataFrame) => {
jest.spyOn(DataSourceWithBackend.prototype, 'query').mockReturnValue(of({ data: [frame] }));
return lastValueFrom(ds.query({ targets: [] } as unknown as Parameters<DataSource['query']>[0]));
};

afterEach(() => {
jest.restoreAllMocks();
});

it('appends a traceId field with an internal link when configured', async () => {
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'trace-uid' } });
const frame = logFrame({ trace: 'projects/my-proj/traces/abc123', traceId: 'abc123', spanId: 'def' });
const response = await runQuery(ds, frame);

const traceField = response.data[0].fields.find((f: { name: string }) => f.name === 'traceId');
expect(traceField).toBeDefined();
expect(Array.from(traceField.values)).toEqual(['abc123']);
expect(traceField.config.links).toEqual([
{
title: 'View trace',
url: '',
internal: {
datasourceUid: 'trace-uid',
datasourceName: 'Google Cloud Trace',
query: { refId: 'trace', queryType: 'traceID', traceId: 'abc123', projectId: 'my-proj' },
},
},
]);
});

it('leaves frames untouched when logsToTraces is not configured', async () => {
const ds = makeDataSource();
const frame = logFrame({ trace: 'projects/my-proj/traces/abc123', traceId: 'abc123' });
const response = await runQuery(ds, frame);
expect(response.data[0].fields).toHaveLength(2);
});

it('leaves frames untouched when the configured datasource does not resolve', async () => {
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'gone' } });
const frame = logFrame({ trace: 'projects/my-proj/traces/abc123', traceId: 'abc123' });
const response = await runQuery(ds, frame);
expect(response.data[0].fields).toHaveLength(2);
});

it('leaves frames without a traceId label untouched', async () => {
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'trace-uid' } });
const frame = logFrame({ level: 'info' });
const response = await runQuery(ds, frame);
expect(response.data[0].fields).toHaveLength(2);
});

it('removes the traceId label from the content field once the linked field is added', async () => {
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'trace-uid' } });
const frame = logFrame({ trace: 'projects/my-proj/traces/abc123', traceId: 'abc123' });
const response = await runQuery(ds, frame);

const contentField = response.data[0].fields.find((f: { name: string }) => f.name === 'content');
expect(contentField.labels).toEqual({ trace: 'projects/my-proj/traces/abc123' });
expect(response.data[0].fields.find((f: { name: string }) => f.name === 'traceId')).toBeDefined();
});

it('falls back to the default project when the trace label is not a resource path', async () => {
const ds = makeDataSource({
logsToTraces: { datasourceUid: 'trace-uid' },
defaultProject: 'my-default-proj',
});
const frame = logFrame({ trace: 'abc123', traceId: 'abc123' });
const response = await runQuery(ds, frame);

const traceField = response.data[0].fields.find((f: { name: string }) => f.name === 'traceId');
expect(traceField.config.links[0].internal.query.projectId).toBe('my-default-proj');
});

it('skips the link when no project can be determined', async () => {
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'trace-uid' } });
const frame = logFrame({ trace: 'abc123', traceId: 'abc123' });
const response = await runQuery(ds, frame);
expect(response.data[0].fields).toHaveLength(2);
});
});

describe('applyTemplateVariables', () => {
const passthroughTemplateSrv = {
replace: (s?: string) => s ?? '',
} as unknown as TemplateSrv;

it('defaults projectId to the configured default project when the query has none', () => {
const ds = makeDataSource({ defaultProject: 'my-default-proj' }, passthroughTemplateSrv);
const query = { refId: 'A', query: '"abc123"' } as Query;
expect(ds.applyTemplateVariables(query, {} as ScopedVars).projectId).toBe('my-default-proj');
});

it('keeps the query projectId when present', () => {
const ds = makeDataSource({ defaultProject: 'my-default-proj' }, passthroughTemplateSrv);
const query = { refId: 'A', projectId: 'explicit-proj' } as Query;
expect(ds.applyTemplateVariables(query, {} as ScopedVars).projectId).toBe('explicit-proj');
});

it('uses the GCE default project for span-link queries under GCE auth', () => {
const ds = makeDataSource(
{ authenticationType: GoogleAuthType.GCE, gceDefaultProject: 'gce-proj' },
passthroughTemplateSrv
);
const query = { refId: 'A', query: '"abc123"' } as Query;
expect(ds.applyTemplateVariables(query, {} as ScopedVars).projectId).toBe('gce-proj');
});

it('leaves projectId empty for non-span-link queries so misconfiguration fails loudly', () => {
const ds = makeDataSource({ defaultProject: 'my-default-proj' }, passthroughTemplateSrv);
const query = { refId: 'A' } as Query;
expect(ds.applyTemplateVariables(query, {} as ScopedVars).projectId).toBe('');
});
});
});

const makeDataSource = (overrides?: { projectListFilter?: string; logBucketFilter?: string }) => {
const makeDataSource = (overrides?: Partial<CloudLoggingOptions>, templateSrv?: TemplateSrv) => {
return new DataSource({
id: random(100),
type: 'googlecloud-logging-datasource',
Expand All @@ -245,5 +381,5 @@ const makeDataSource = (overrides?: { projectListFilter?: string; logBucketFilte
},
name: 'something',
readOnly: true,
});
}, templateSrv);
}
Loading