From ddc788417d44cb186023c7ad9370cad69ab791c0 Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Sun, 19 Jul 2026 14:04:54 +0000 Subject: [PATCH 1/4] add logs to traces --- CHANGELOG.md | 4 ++ README.md | 14 ++++++ src/ConfigEditor.tsx | 43 +++++++++++++++++- src/datasource.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++-- src/datasource.ts | 84 ++++++++++++++++++++++++++++++++++-- src/types.ts | 9 ++++ 6 files changed, 244 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a7b0f..aafc725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## Unreleased +* Add **Logs to traces** correlation: select a tracing data source (e.g. Google Cloud Trace) in the data source settings, and log entries carrying a trace ID get a "View trace" link in the log details +* Support queries arriving via Grafana's trace-to-logs span links (`query` field): interpolate template variables and fall back to the configured default project when the span link carries no project ID + ## 1.6.4 (2026-05-06) * **Bump minimum Grafana version to 11.2.0.** Earlier versions have an upstream `` 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. diff --git a/README.md b/README.md index 4216ae6..8c9db8f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ If a pattern contains invalid regex syntax, it is treated as a literal string ma ### Logs to traces -You can link log entries to a tracing data source (e.g. [Google Cloud Trace](https://grafana.com/grafana/plugins/googlecloud-trace-datasource/)). In the data source settings, under **Logs to traces**, select the 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. +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//traces/` 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: diff --git a/pkg/plugin/cloudlogging/cloudlogging.go b/pkg/plugin/cloudlogging/cloudlogging.go index e459803..cd9d92b 100644 --- a/pkg/plugin/cloudlogging/cloudlogging.go +++ b/pkg/plugin/cloudlogging/cloudlogging.go @@ -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//traces/` 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 != "" { diff --git a/src/ConfigEditor.tsx b/src/ConfigEditor.tsx index 01dbdc2..1b0afa5 100644 --- a/src/ConfigEditor.tsx +++ b/src/ConfigEditor.tsx @@ -171,37 +171,31 @@ export class ConfigEditor extends PureComponent { const logsToTraces = (props: Props) => { const { options, onOptionsChange } = props; + const setLogsToTraces = (uid?: string) => + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + logsToTraces: uid ? { datasourceUid: uid } : undefined, + }, + }); return (
ds.type === 'googlecloud-trace-datasource'} noDefault={true} width={40} current={options.jsonData.logsToTraces?.datasourceUid ?? null} - onChange={(ds) => - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - logsToTraces: { datasourceUid: ds.uid }, - }, - }) - } - onClear={() => - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - logsToTraces: undefined, - }, - }) - } + onChange={(ds) => setLogsToTraces(ds.uid)} + onClear={() => setLogsToTraces()} />
diff --git a/src/datasource.test.ts b/src/datasource.test.ts index 28dbf25..92a5fd0 100644 --- a/src/datasource.test.ts +++ b/src/datasource.test.ts @@ -303,6 +303,35 @@ describe('Google Cloud Logging Data Source', () => { 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', () => { @@ -321,6 +350,21 @@ describe('Google Cloud Logging Data Source', () => { 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(''); + }); }); }); diff --git a/src/datasource.ts b/src/datasource.ts index 5ac4166..e1d873c 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -25,8 +25,8 @@ import { ScopedVars, } from '@grafana/data'; import { DataSourceWithBackend, getBackendSrv, getDataSourceSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { lastValueFrom, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { from, lastValueFrom, Observable } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; import { CloudLoggingOptions, Query } from './types'; import { CloudLoggingVariableSupport } from './variables'; @@ -89,13 +89,20 @@ export class DataSource extends DataSourceWithBackend} */ query(request: DataQueryRequest): Observable { + // When a target has no projectId, applyTemplateVariables falls back to + // defaultProjectSync(), which for GCE auth reads a lazily-populated + // cache; resolve it before the backend call so the fallback is available. + const needsDefaultProject = request.targets.some((t) => !t.hide && !t.projectId); + const base = + needsDefaultProject && this.instanceSettings.jsonData.authenticationType === 'gce' + ? from(this.ensureGCEDefaultProject().catch(() => {})).pipe(mergeMap(() => super.query(request))) + : super.query(request); const uid = this.instanceSettings.jsonData.logsToTraces?.datasourceUid; const traceDs = uid ? getDataSourceSrv().getInstanceSettings(uid) : undefined; if (!traceDs) { - return super.query(request); + return base; } - return super.query(request).pipe( + return base.pipe( map((response) => ({ ...response, data: response.data.map((frame: DataFrame) => this.addTraceLinkField(frame, traceDs.uid, traceDs.name)), @@ -298,10 +313,21 @@ export class DataSource extends DataSourceWithBackend f.name === 'content'); const labels = contentField?.labels; const traceId = labels?.['traceId']; - if (!contentField || !traceId || frame.fields.some((f) => f.name === 'traceId')) { + if (!contentField || !labels || !traceId || frame.fields.some((f) => f.name === 'traceId')) { + return frame; + } + // LogEntry.trace is a free-form string; when it isn't the canonical + // resource path, fall back to the default project, and if that is also + // unset skip the link entirely — Cloud Trace errors on an empty project, + // so no link beats a broken one. + const projectId = + labels['trace']?.match(/^projects\/([^/]+)\/traces\//)?.[1] ?? this.defaultProjectSync(); + if (!projectId) { return frame; } - const projectId = (labels?.['trace'] ?? '').match(/^projects\/([^/]+)\/traces\//)?.[1] ?? ''; + // The linked field replaces the label in the log details view; leaving + // both would show `traceId` twice (once without the link). + delete labels['traceId']; const rowCount = contentField.values.length; frame.fields.push({ name: 'traceId', @@ -332,10 +358,13 @@ export class DataSource extends DataSourceWithBackend Date: Mon, 20 Jul 2026 03:17:32 +0000 Subject: [PATCH 3/4] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8c9db8f..8d71d4c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ jsonData: 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: /;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 From c5bd05d6f3506044dd089ea8a654f0b7fcb4261d Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Mon, 20 Jul 2026 03:27:52 +0000 Subject: [PATCH 4/4] prepare for a new release --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c29a5a0..293c7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## Unreleased +## 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//traces/` 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 diff --git a/package.json b/package.json index fcb0384..0ec20f5 100644 --- a/package.json +++ b/package.json @@ -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",