Skip to content

Commit 4e7b471

Browse files
authored
prep for 1.7.1 (#219)
1 parent c4635b4 commit 4e7b471

7 files changed

Lines changed: 203 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Changelog
2+
## 1.7.1 (2026-08-17)
3+
* Add **Get trace project from the query** option to the Logs to traces settings (`logsToTraces.projectIdFromQuery`). When enabled, "View trace" links use the project of the query that produced the log entry (falling back to the default project) instead of the project embedded in the entry's `trace` path — for setups where logs are routed through a central logging project that stamps its own ID into the trace path. The setting is preserved when the trace data source is changed or cleared
4+
* Fix "View trace" links under GCE authentication intermittently missing for entries whose `trace` label is not a canonical resource path: the auto-detected default project is now resolved whenever a trace data source is configured (concurrently with the query), shared across simultaneous queries, and no longer re-fetched before every query after a failed lookup
5+
26
## 1.7.0 (2026-07-20)
37
* 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
48
* 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

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,17 @@ If a pattern contains invalid regex syntax, it is treated as a literal string ma
109109

110110
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.
111111

112+
If your logs are routed through a central logging project (for example, an OpenTelemetry collector writes to one project and a [log sink](https://cloud.google.com/logging/docs/export/configure_export_v2) forwards entries to tenant projects), the `trace` path is stamped with the routing project's ID even though the trace lives in the tenant project, so the parsed project is wrong. Enable **Get trace project from the query** to make the link use the project of the query that produced the log entry instead (falling back to the data source's default project; if neither resolves, the link is omitted). Only the link target changes — the raw `trace` label in the log details still shows the original path.
113+
112114
Provisioning example:
113115

114116
```yaml
115117
jsonData:
116118
logsToTraces:
117119
datasourceUid: my-cloud-trace-datasource-uid
120+
# Optional: use the queried project for View trace links instead of the
121+
# project in the log entry's trace path (for centrally routed logs)
122+
# projectIdFromQuery: true
118123
```
119124

120125
> **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.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "googlecloud-logging-datasource",
3-
"version": "1.7.0",
3+
"version": "1.7.1",
44
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Logging logs in Grafana.",
55
"scripts": {
66
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",

src/ConfigEditor.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ConnectionConfig, GoogleAuthType } from '@grafana/google-sdk';
1919
import { DataSourcePicker } from '@grafana/runtime';
2020
import { Checkbox, Field, FieldSet, Input, SecretInput, Select, TextArea } from '@grafana/ui';
2121
import React, { PureComponent } from 'react';
22-
import { authTypes, CloudLoggingOptions, DataSourceSecureJsonData } from './types';
22+
import { authTypes, CloudLoggingOptions, DataSourceSecureJsonData, LogsToTracesOptions } from './types';
2323

2424
export type Props = DataSourcePluginOptionsEditorProps<CloudLoggingOptions, DataSourceSecureJsonData>;
2525

@@ -171,12 +171,14 @@ export class ConfigEditor extends PureComponent<Props> {
171171

172172
const logsToTraces = (props: Props) => {
173173
const { options, onOptionsChange } = props;
174-
const setLogsToTraces = (uid?: string) =>
174+
// Merge-style updater so changing one option (e.g. swapping the trace
175+
// data source) never silently drops the others.
176+
const setLogsToTraces = (patch: Partial<LogsToTracesOptions>) =>
175177
onOptionsChange({
176178
...options,
177179
jsonData: {
178180
...options.jsonData,
179-
logsToTraces: uid ? { datasourceUid: uid } : undefined,
181+
logsToTraces: { ...options.jsonData.logsToTraces, ...patch },
180182
},
181183
});
182184
return (
@@ -194,10 +196,23 @@ const logsToTraces = (props: Props) => {
194196
noDefault={true}
195197
width={40}
196198
current={options.jsonData.logsToTraces?.datasourceUid ?? null}
197-
onChange={(ds) => setLogsToTraces(ds.uid)}
198-
onClear={() => setLogsToTraces()}
199+
onChange={(ds) => setLogsToTraces({ datasourceUid: ds.uid })}
200+
onClear={() => setLogsToTraces({ datasourceUid: undefined })}
199201
/>
200202
</Field>
203+
{/* Options survive clearing the picker, so keep the row visible while
204+
the flag is set — it must never become hidden, uneditable state. */}
205+
{(options.jsonData.logsToTraces?.datasourceUid || options.jsonData.logsToTraces?.projectIdFromQuery) && (
206+
<Checkbox
207+
id="logs-to-traces-project-from-query"
208+
label="Get trace project from the query"
209+
description="Use the query's project ID (or the default project) for 'View trace' links instead of the project in the log entry's trace field. If neither resolves, the link is omitted. Enable this when logs are routed through a central logging project that stamps its own ID into the trace path."
210+
value={options.jsonData.logsToTraces?.projectIdFromQuery ?? false}
211+
onChange={(e) => setLogsToTraces({ projectIdFromQuery: e.currentTarget.checked })}
212+
onPointerEnterCapture={undefined}
213+
onPointerLeaveCapture={undefined}
214+
/>
215+
)}
201216
</FieldSet>
202217
);
203218
};

src/datasource.test.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,9 @@ describe('Google Cloud Logging Data Source', () => {
253253
],
254254
});
255255

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

261261
afterEach(() => {
@@ -332,6 +332,104 @@ describe('Google Cloud Logging Data Source', () => {
332332
const response = await runQuery(ds, frame);
333333
expect(response.data[0].fields).toHaveLength(2);
334334
});
335+
336+
it('keeps the trace-path project when targets carry a projectId and the flag is off', async () => {
337+
const ds = makeDataSource({ logsToTraces: { datasourceUid: 'trace-uid' } });
338+
const frame = logFrame({ trace: 'projects/my-proj/traces/abc123', traceId: 'abc123' });
339+
const targets = [{ refId: 'A', projectId: 'other-proj' } as Query];
340+
const response = await runQuery(ds, frame, targets);
341+
342+
const traceField = response.data[0].fields.find((f: { name: string }) => f.name === 'traceId');
343+
expect(traceField.config.links[0].internal.query.projectId).toBe('my-proj');
344+
});
345+
346+
it('warms the GCE default project for the trace-link fallback when the flag is off', async () => {
347+
const ds = makeDataSource({
348+
logsToTraces: { datasourceUid: 'trace-uid' },
349+
authenticationType: GoogleAuthType.GCE,
350+
});
351+
const gceSpy = jest.spyOn(ds, 'getGCEDefaultProject').mockResolvedValue('gce-proj');
352+
// The target carries a projectId, so the warm-up is not needed to
353+
// build the request — only the non-canonical trace-path fallback
354+
// consumes it when the response is mapped.
355+
const frame = logFrame({ trace: 'abc123', traceId: 'abc123' });
356+
const targets = [{ refId: 'A', projectId: 'some-proj' } as Query];
357+
const response = await runQuery(ds, frame, targets);
358+
359+
expect(gceSpy).toHaveBeenCalled();
360+
const traceField = response.data[0].fields.find((f: { name: string }) => f.name === 'traceId');
361+
expect(traceField.config.links[0].internal.query.projectId).toBe('gce-proj');
362+
});
363+
364+
describe('with projectIdFromQuery enabled', () => {
365+
const stubTemplateSrv = {
366+
replace: (s?: string) => (s === '$project' ? 'tenant-proj' : s ?? ''),
367+
} as unknown as TemplateSrv;
368+
369+
const makeFlagOnDataSource = (overrides?: Partial<CloudLoggingOptions>) =>
370+
makeDataSource(
371+
{ logsToTraces: { datasourceUid: 'trace-uid', projectIdFromQuery: true }, ...overrides },
372+
stubTemplateSrv
373+
);
374+
375+
const linkProject = (response: { data: any[] }) =>
376+
response.data[0].fields.find((f: { name: string }) => f.name === 'traceId')?.config.links[0].internal
377+
.query.projectId;
378+
379+
const routedFrame = () =>
380+
logFrame({ trace: 'projects/routing-proj/traces/abc123', traceId: 'abc123' });
381+
382+
it('uses the projectId of the target that produced the frame, not the trace path', async () => {
383+
const ds = makeFlagOnDataSource();
384+
const targets = [{ refId: 'A', projectId: 'tenant-proj' } as Query];
385+
const response = await runQuery(ds, routedFrame(), targets);
386+
expect(linkProject(response)).toBe('tenant-proj');
387+
});
388+
389+
it('interpolates template variables in the target projectId', async () => {
390+
const ds = makeFlagOnDataSource();
391+
const targets = [{ refId: 'A', projectId: '$project' } as Query];
392+
const response = await runQuery(ds, routedFrame(), targets);
393+
expect(linkProject(response)).toBe('tenant-proj');
394+
});
395+
396+
it('falls back to the default project when no target matches the frame', async () => {
397+
const ds = makeFlagOnDataSource({ defaultProject: 'my-default-proj' });
398+
const response = await runQuery(ds, routedFrame(), []);
399+
expect(linkProject(response)).toBe('my-default-proj');
400+
});
401+
402+
it('falls back to the default project when the matching target has no projectId', async () => {
403+
const ds = makeFlagOnDataSource({ defaultProject: 'my-default-proj' });
404+
const targets = [{ refId: 'A', projectId: '' } as Query];
405+
const response = await runQuery(ds, routedFrame(), targets);
406+
expect(linkProject(response)).toBe('my-default-proj');
407+
});
408+
409+
it('ignores hidden targets when resolving the project', async () => {
410+
const ds = makeFlagOnDataSource({ defaultProject: 'my-default-proj' });
411+
const targets = [{ refId: 'A', projectId: 'tenant-proj', hide: true } as Query];
412+
const response = await runQuery(ds, routedFrame(), targets);
413+
expect(linkProject(response)).toBe('my-default-proj');
414+
});
415+
416+
it('omits the link when neither a target project nor a default project resolves', async () => {
417+
const ds = makeFlagOnDataSource();
418+
const response = await runQuery(ds, routedFrame(), []);
419+
expect(response.data[0].fields).toHaveLength(2);
420+
});
421+
422+
it('pre-resolves the GCE default project so the link fallback works under GCE auth', async () => {
423+
const ds = makeFlagOnDataSource({ authenticationType: GoogleAuthType.GCE });
424+
const gceSpy = jest.spyOn(ds, 'getGCEDefaultProject').mockResolvedValue('gce-proj');
425+
// All targets carry a projectId, but none matches the frame's
426+
// refId, so the link must fall back to the GCE project.
427+
const targets = [{ refId: 'B', projectId: 'other-proj' } as Query];
428+
const response = await runQuery(ds, routedFrame(), targets);
429+
expect(gceSpy).toHaveBeenCalled();
430+
expect(linkProject(response)).toBe('gce-proj');
431+
});
432+
});
335433
});
336434

337435
describe('applyTemplateVariables', () => {

src/datasource.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,25 @@ export class DataSource extends DataSourceWithBackend<Query, CloudLoggingOptions
109109
return this.getResource(`gceDefaultProject`);
110110
}
111111

112+
/**
113+
* Resolve and cache the GCE default project. Single-flight: concurrent
114+
* queries share one resource call, and a failed lookup is not retried
115+
* until the next page load — retrying per query would gate every query
116+
* on a full failing round-trip.
117+
*/
118+
private gceDefaultProjectPromise: Promise<void> | null = null;
119+
112120
async ensureGCEDefaultProject() {
113121
const { authenticationType, gceDefaultProject } = this.instanceSettings.jsonData;
114-
if (authenticationType === 'gce' && !gceDefaultProject) {
115-
this.instanceSettings.jsonData.gceDefaultProject = await this.getGCEDefaultProject();
122+
if (authenticationType !== 'gce' || gceDefaultProject) {
123+
return;
116124
}
125+
if (!this.gceDefaultProjectPromise) {
126+
this.gceDefaultProjectPromise = this.getGCEDefaultProject().then((project) => {
127+
this.instanceSettings.jsonData.gceDefaultProject = project;
128+
});
129+
}
130+
return this.gceDefaultProjectPromise;
117131
}
118132

119133
/**
@@ -280,23 +294,48 @@ export class DataSource extends DataSourceWithBackend<Query, CloudLoggingOptions
280294
* @returns a modified {@link Observable<DataQueryResponse>}
281295
*/
282296
query(request: DataQueryRequest<Query>): Observable<DataQueryResponse> {
283-
// When a target has no projectId, applyTemplateVariables falls back to
284-
// defaultProjectSync(), which for GCE auth reads a lazily-populated
285-
// cache; resolve it before the backend call so the fallback is available.
286-
const needsDefaultProject = request.targets.some((t) => !t.hide && !t.projectId);
287-
const base =
288-
needsDefaultProject && this.instanceSettings.jsonData.authenticationType === 'gce'
289-
? from(this.ensureGCEDefaultProject().catch(() => {})).pipe(mergeMap(() => super.query(request)))
290-
: super.query(request);
291297
const uid = this.instanceSettings.jsonData.logsToTraces?.datasourceUid;
292298
const traceDs = uid ? getDataSourceSrv().getInstanceSettings(uid) : undefined;
299+
// For GCE auth, defaultProjectSync() reads a lazily-populated cache.
300+
// applyTemplateVariables falls back to it while the backend request is
301+
// built, so when a target has no projectId the cache must resolve before
302+
// super.query. The trace-link fallbacks read the same cache but only
303+
// once the response is mapped, so for them the warm-up runs concurrently
304+
// with the query and is awaited in the response pipe below.
305+
const needsDefaultProjectForRequest = request.targets.some((t) => !t.hide && !t.projectId);
306+
const gceWarmup =
307+
this.instanceSettings.jsonData.authenticationType === 'gce' && (needsDefaultProjectForRequest || !!traceDs)
308+
? this.ensureGCEDefaultProject().catch(() => {})
309+
: undefined;
310+
const base =
311+
gceWarmup && needsDefaultProjectForRequest
312+
? from(gceWarmup).pipe(mergeMap(() => super.query(request)))
313+
: super.query(request);
293314
if (!traceDs) {
294315
return base;
295316
}
296-
return base.pipe(
317+
// With projectIdFromQuery enabled, trace links use the project of the
318+
// query target that produced each frame (matched by refId) instead of
319+
// the project embedded in the log entry's trace path.
320+
const projectByRefId = this.instanceSettings.jsonData.logsToTraces?.projectIdFromQuery
321+
? new Map(
322+
request.targets
323+
.filter((t) => !t.hide && t.projectId)
324+
.map((t) => [t.refId, this.templateSrv.replace(t.projectId, request.scopedVars)])
325+
)
326+
: undefined;
327+
const awaited = gceWarmup ? base.pipe(mergeMap((response) => from(gceWarmup.then(() => response)))) : base;
328+
return awaited.pipe(
297329
map((response) => ({
298330
...response,
299-
data: response.data.map((frame: DataFrame) => this.addTraceLinkField(frame, traceDs.uid, traceDs.name)),
331+
data: response.data.map((frame: DataFrame) =>
332+
this.addTraceLinkField(
333+
frame,
334+
traceDs.uid,
335+
traceDs.name,
336+
projectByRefId ? projectByRefId.get(frame.refId ?? '') || this.defaultProjectSync() : undefined
337+
)
338+
),
300339
}))
301340
);
302341
}
@@ -308,8 +347,19 @@ export class DataSource extends DataSourceWithBackend<Query, CloudLoggingOptions
308347
* the trace ID as its own field carrying an internal data link, so the
309348
* log details panel renders a "View trace" link that opens the configured
310349
* tracing data source — the same mechanism as Loki's derived fields.
350+
*
351+
* `projectIdOverride` is defined when the projectIdFromQuery setting is
352+
* on: it is used verbatim as the link's project and the trace path is
353+
* never parsed — the entry may be stamped with a routing project the user
354+
* explicitly opted out of. An empty override means nothing resolved, so
355+
* the link is omitted.
311356
*/
312-
addTraceLinkField(frame: DataFrame, datasourceUid: string, datasourceName: string): DataFrame {
357+
addTraceLinkField(
358+
frame: DataFrame,
359+
datasourceUid: string,
360+
datasourceName: string,
361+
projectIdOverride?: string
362+
): DataFrame {
313363
const contentField = frame.fields.find((f) => f.name === 'content');
314364
const labels = contentField?.labels;
315365
const traceId = labels?.['traceId'];
@@ -321,7 +371,9 @@ export class DataSource extends DataSourceWithBackend<Query, CloudLoggingOptions
321371
// unset skip the link entirely — Cloud Trace errors on an empty project,
322372
// so no link beats a broken one.
323373
const projectId =
324-
labels['trace']?.match(/^projects\/([^/]+)\/traces\//)?.[1] ?? this.defaultProjectSync();
374+
projectIdOverride !== undefined
375+
? projectIdOverride
376+
: labels['trace']?.match(/^projects\/([^/]+)\/traces\//)?.[1] ?? this.defaultProjectSync();
325377
if (!projectId) {
326378
return frame;
327379
}

0 commit comments

Comments
 (0)