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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
dist/
coverage/
coverage/
.eslintcache
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# Changelog
## 1.8.0 (2026-09-03)
* **Breaking: logs responses now use Grafana's dataplane `log-lines` format** ([#221](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/issues/221)). Each query returns a single frame with one row per log entry instead of one frame per entry. Fields are `timestamp`, `body`, `severity`, `id` (the entry's insert ID), `labels` (per-row JSON object) and `traceId`; previously they were `time` and `content` with the metadata attached as labels on `content`. Grafana can now identify each log line uniquely, which fixes log details expanding every line at once, the log list jumping to the top on click, broken permalinks and dedup, and the Logs Table showing only the first line. Dashboards that reference the old `content` or `time` field names in transformations or Table panels need updating
* "View trace" links are resolved per log line. The project comes from each entry's own trace path (or the query/default project, as before), so a single result set spanning several projects links each line to the right trace
* An empty result now returns an empty frame with the logs schema instead of no frame
* Fix the query variable editor getting stuck on "Loading..." ([#212](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/issues/212)). The editor no longer fetches buckets for an empty project or lists projects before they are needed; option lists load per scope, failures are shown inline instead of freezing the editor, and variable query errors (for example a disabled Cloud Resource Manager API) now surface in Grafana instead of silently producing no values. Selecting a scope is saved immediately
* Fix an empty query text producing an unparseable filter (a leading `AND`); the time range alone is sent instead
* Fix JWT authentication failing with "An error occurred within the plugin" when the data source is provisioned (YAML, Terraform, environment variables) and the private key contains literal `\n` escape sequences instead of line breaks ([#76](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/issues/76), [#202](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/issues/202)). The key is now read the same way as in the Google Cloud Monitoring data source, which also adds support for `privateKeyPath`. A key that still fails to parse produces an explanatory error instead of a generic one

## 1.7.2 (2026-08-17)
* Update dependencies to address security vulnerabilities flagged by the Grafana plugin review: js-yaml (CVE-2026-59869) and nanoid (CVE-2026-67213) in the frontend build toolchain
* Upgrade the Grafana Go plugin SDK from v0.290.0 to v0.296.2
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,28 @@ datasources:
# universeDomain: googleapis.com
```

To provision a service account key (JWT authentication) instead, supply the fields from the service account JSON file. The private key goes in `secureJsonData`, either inline or via a file path:

```yaml
apiVersion: 1

datasources:
- name: Google Cloud Logging
type: googlecloud-logging-datasource
access: proxy
jsonData:
authenticationType: jwt
clientEmail: my-service-account@my-project.iam.gserviceaccount.com
defaultProject: my-project
tokenUri: https://oauth2.googleapis.com/token
# Alternative to secureJsonData.privateKey: read the PEM file from disk
# privateKeyPath: /etc/secrets/gcp-logging-private-key.pem
secureJsonData:
privateKey: $__file{/etc/secrets/gcp-logging-private-key.pem}
```

The same `jsonData` and `secureJsonData` fields work with the Grafana Terraform provider's `grafana_data_source` resource. The `privateKey` value is the `private_key` field of the service account JSON file. It may be passed with real line breaks or with the literal `\n` escape sequences as they appear in the JSON file; both are accepted.

### Supported variables

The plugin currently supports variables for logging scopes. For example, you can define a project variable and switch between projects. The following screenshot shows an example using project, bucket, and view.
Expand Down
9 changes: 9 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'eslint/config';
import baseConfig from './.config/eslint.config.mjs';

export default defineConfig([
{
ignores: ['dist/**', 'node_modules/**', '.config/**', 'coverage/**', 'playwright-report/**', 'test-results/**'],
},
...baseConfig,
]);
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.7.2",
"version": "1.8.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
18 changes: 14 additions & 4 deletions pkg/plugin/cloudlogging/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,21 @@ type Query struct {
}

// String is the query formatted for querying GCP
// It is the query text, with the time range constraints appended
// It is the query text, with the time range constraints appended.
//
// No parentheses are needed around the user's filter: in the Logging query
// language OR binds tighter than AND, so `a OR b AND timestamp >= x` already
// groups as `(a OR b) AND timestamp >= x`.
//
// An empty filter yields just the time range; a leading `AND` is rejected by
// the API as an unparseable filter.
func (q *Query) String() string {
return fmt.Sprintf(`%s AND timestamp >= "%s" AND timestamp <= "%s"`,
q.Filter, q.TimeRange.From, q.TimeRange.To,
)
timeRange := fmt.Sprintf(`timestamp >= "%s" AND timestamp <= "%s"`, q.TimeRange.From, q.TimeRange.To)
filter := strings.TrimSpace(q.Filter)
if filter == "" {
return timeRange
}
return filter + " AND " + timeRange
}

// ListProjects returns the project IDs of all visible projects.
Expand Down
47 changes: 27 additions & 20 deletions pkg/plugin/cloudlogging/cloudlogging.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,18 @@ func GetLogEntryMessage(entry *loggingpb.LogEntry) (string, error) {
}
}

// GetLogLabels flattens a log entry's labels + resource labels into a map
// GetLogLabels flattens a log entry's labels + resource labels into a map.
//
// The entry's insert ID, severity and bare trace ID are deliberately not
// included: they are emitted as dedicated `id`, `severity` and `traceId`
// frame fields (see plugin.go), and repeating them here would show them
// twice in Grafana's log details.
func GetLogLabels(entry *loggingpb.LogEntry) data.Labels {
labels := make(data.Labels)
for k, v := range entry.GetLabels() {
labels[fmt.Sprintf("labels.\"%s\"", k)] = v
}

labels["id"] = entry.GetInsertId()
// This is how severity is set
labels["level"] = GetLogLevel(entry.GetSeverity())

resource := entry.GetResource()
if resourceType := resource.GetType(); resourceType != "" {
labels["resource.type"] = resourceType
Expand All @@ -98,7 +99,7 @@ func GetLogLabels(entry *loggingpb.LogEntry) data.Labels {
if err := t.ProtoPayload.UnmarshalTo(&a); err != nil {
log.DefaultLogger.Error("Could not get AuditLog payload out of LogEntry", "error", err)
} else {
byteArr, _ := json.Marshal(a)
byteArr, _ := json.Marshal(&a)
var inInterface map[string]*structpb.Value
json.Unmarshal(byteArr, &inInterface)
for k, v := range inInterface {
Expand All @@ -110,7 +111,7 @@ func GetLogLabels(entry *loggingpb.LogEntry) data.Labels {
if err := t.ProtoPayload.UnmarshalTo(&r); err != nil {
log.DefaultLogger.Error("Could not get RequestLog payload out of LogEntry", "error", err)
} else {
byteArr, _ := json.Marshal(r)
byteArr, _ := json.Marshal(&r)
var inInterface map[string]*structpb.Value
json.Unmarshal(byteArr, &inInterface)
for k, v := range inInterface {
Expand All @@ -136,26 +137,32 @@ func GetLogLabels(entry *loggingpb.LogEntry) data.Labels {

// 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 != "" {
trace := entry.GetTrace()
// addTraceLinkField) depends on the `trace` label carrying the raw
// LogEntry value in the canonical `projects/<project>/traces/<id>` form —
// it re-parses that path per row to extract the project for the
// "View trace" link. Changing its name or format silently breaks that
// feature; no test crosses the Go/TS boundary.
if trace := entry.GetTrace(); trace != "" {
labels["trace"] = trace
labels["traceId"] = strings.Split(trace, "/")[len(strings.Split(trace, "/"))-1]
}
if spanId != "" {
labels["spanId"] = entry.GetSpanId()
if spanId := entry.GetSpanId(); spanId != "" {
labels["spanId"] = spanId
}

return labels
}

// GetTraceID returns the bare trace ID of a log entry (the last path segment
// of LogEntry.trace, which is normally `projects/<project>/traces/<id>`), or
// an empty string when the entry carries no trace.
func GetTraceID(entry *loggingpb.LogEntry) string {
trace := entry.GetTrace()
if trace == "" {
return ""
}
return trace[strings.LastIndex(trace, "/")+1:]
}

// GetLogLevel maps the string value of a LogSeverity to one supported by Grafana
func GetLogLevel(severity ltype.LogSeverity) string {
switch severity {
Expand Down
76 changes: 49 additions & 27 deletions pkg/plugin/cloudlogging/cloudlogging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,7 @@ func TestGetLogLabels(t *testing.T) {
entry: &loggingpb.LogEntry{
InsertId: "insert-id",
},
expected: data.Labels{
"id": "insert-id",
"level": "info",
},
expected: data.Labels{},
},
{
name: "no log labels, but resource with labels",
Expand All @@ -270,10 +267,8 @@ func TestGetLogLabels(t *testing.T) {
},
},
expected: data.Labels{
"id": "insert-id",
"resource.labels.instance_id": "123456",
"resource.type": "gce_instance",
"level": "info",
},
},
{
Expand All @@ -292,12 +287,10 @@ func TestGetLogLabels(t *testing.T) {
},
},
expected: data.Labels{
"id": "insert-id2",
"labels.\"pid\"": "111",
"labels.\"LOG_BUCKET_NUM\"": "1",
"resource.labels.instance_id": "98765",
"resource.type": "cloudsql_database",
"level": "info",
},
},
{
Expand Down Expand Up @@ -335,15 +328,13 @@ func TestGetLogLabels(t *testing.T) {
},
},
expected: data.Labels{
"id": "insert-id4",
"labels.\"logging.googleapis.com/instrumentation_source\"": "agent.googleapis.com/thirdparty",
"jsonPayload.tid": "222",
"jsonPayload.db": "database-experiencing-error",
"jsonPayload.is_serious": "true",
"labels.\"LOG_BUCKET_NUM\"": "1",
"resource.labels.instance_id": "98765",
"resource.type": "gce_instance",
"level": "alert",
"jsonPayload.service_context.service": "some-service",
"jsonPayload.service_context.version": "v42",
},
Expand All @@ -357,8 +348,6 @@ func TestGetLogLabels(t *testing.T) {
},
},
expected: data.Labels{
"id": "insert-id5",
"level": "info",
"textPayload": "This is a text log message",
},
},
Expand All @@ -370,11 +359,8 @@ func TestGetLogLabels(t *testing.T) {
SpanId: "000000000000004a",
},
expected: data.Labels{
"id": "insert-id6",
"level": "info",
"trace": "projects/my-project/traces/06796866738c859f2f19b7cfb3214824",
"traceId": "06796866738c859f2f19b7cfb3214824",
"spanId": "000000000000004a",
"trace": "projects/my-project/traces/06796866738c859f2f19b7cfb3214824",
"spanId": "000000000000004a",
},
},
{
Expand All @@ -401,8 +387,6 @@ func TestGetLogLabels(t *testing.T) {
},
},
expected: data.Labels{
"id": "insert-id7",
"level": "info",
"jsonPayload.string_field": "test",
"jsonPayload.number_field": "42.5",
"jsonPayload.bool_field": "false",
Expand All @@ -421,10 +405,7 @@ func TestGetLogLabels(t *testing.T) {
},
},
},
expected: data.Labels{
"id": "insert-id8",
"level": "info",
},
expected: data.Labels{},
},
{
name: "Proto payload with RequestLog",
Expand All @@ -437,10 +418,7 @@ func TestGetLogLabels(t *testing.T) {
},
},
},
expected: data.Labels{
"id": "insert-id9",
"level": "info",
},
expected: data.Labels{},
},
}

Expand All @@ -451,6 +429,23 @@ func TestGetLogLabels(t *testing.T) {
}
}

func TestGetTraceID(t *testing.T) {
testCases := []struct {
name string
trace string
expected string
}{
{name: "canonical resource path", trace: "projects/my-project/traces/06796866738c859f2f19b7cfb3214824", expected: "06796866738c859f2f19b7cfb3214824"},
{name: "bare id", trace: "06796866738c859f2f19b7cfb3214824", expected: "06796866738c859f2f19b7cfb3214824"},
{name: "no trace", trace: "", expected: ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, cloudlogging.GetTraceID(&loggingpb.LogEntry{Trace: tc.trace}))
})
}
}

// normalizeLabelSpaces collapses runs of whitespace in label values so
// assertions don't depend on protobuf text-format output, which inserts
// unstable whitespace by design.
Expand All @@ -461,3 +456,30 @@ func normalizeLabelSpaces(labels data.Labels) data.Labels {
}
return normalized
}

func TestQueryString(t *testing.T) {
timeRange := struct {
From string
To string
}{From: "2026-01-01T00:00:00Z", To: "2026-01-02T00:00:00Z"}
suffix := `timestamp >= "2026-01-01T00:00:00Z" AND timestamp <= "2026-01-02T00:00:00Z"`

testCases := []struct {
name string
filter string
expected string
}{
{name: "simple filter", filter: `severity >= DEFAULT`, expected: `severity >= DEFAULT AND ` + suffix},
{name: "top-level OR is left as is (OR binds tighter than AND)", filter: `a="1" OR b="2"`, expected: `a="1" OR b="2" AND ` + suffix},
{name: "multi-line filter keeps inner newlines", filter: "a=\"1\"\nb=\"2\"", expected: "a=\"1\"\nb=\"2\" AND " + suffix},
{name: "surrounding whitespace is trimmed", filter: " severity >= DEFAULT \n", expected: `severity >= DEFAULT AND ` + suffix},
{name: "empty filter yields only the time range", filter: "", expected: suffix},
{name: "whitespace-only filter yields only the time range", filter: " \n\t", expected: suffix},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
q := &cloudlogging.Query{Filter: tc.filter, TimeRange: timeRange}
require.Equal(t, tc.expected, q.String())
})
}
}
Loading