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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## Unreleased

### Added

- Automatic retry with exponential backoff (10s, 20s, 40s, 60s; up to 5 attempts) when Cloud Logging returns a quota / rate-limit error (`ResourceExhausted`, `RATE_LIMIT_EXCEEDED`, e.g. the 60 read requests/min limit). A short notice is printed to stderr before each retry; retries stop if the context is cancelled. Interrupted pagination resumes from the last page token.

### Changed

- `--raw` now emits one compact JSON object per line (JSONL), consistent with `--json`, instead of pretty-printed multi-line objects.
- Query page size now matches `--limit` (capped at the API maximum of 1000), so small queries cost a single read request.

### Fixed

- `--where` values containing double quotes (e.g. `--where 'name="gcpCredentials"'`, as documented) or commas no longer fail with `bare " in non-quoted-field`. Each `--where` is now taken verbatim; multiple `--where` flags are still ANDed.
- Raw output mislabelled JSON payloads as `protoPayload`; they are now reported as `jsonPayload`, matching the field paths used in GCP filters (e.g. `jsonPayload.reason` for k8s events). Genuine proto payloads (e.g. audit logs) are still reported as `protoPayload`.

## v1.4.1 - 2026-06-02

### Added
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A CLI tool for querying GCP Cloud Logging for [Taskcluster](https://taskcluster.
- **Schema-aware queries** — Fetches log type definitions from Taskcluster references. Knows that `worker-stopped` has fields `workerId`, `workerPoolId`, `reason`, etc.
- **Field shorthand** — Write `--where 'workerPoolId="proj-misc"'` instead of `--filter 'jsonPayload.Fields.workerPoolId="proj-misc"'`
- **Auto service narrowing** — When a log type is unique to one service (e.g., `worker-stopped` → `worker-manager`), the tool automatically narrows the query
- **Multiple output formats** — Aligned columns (default), JSONL (`--json`), or raw GCP entries (`--raw`)
- **Multiple output formats** — Aligned columns (default), JSONL (`--json`), or full raw GCP entries as JSONL (`--raw`)
- **Result caching** — Queries with absolute time windows are cached for 14 days, keyed by filter + time range hash
- **Environment management** — Configure multiple Taskcluster deployments, auto-detect from `TASKCLUSTER_ROOT_URL`

Expand Down Expand Up @@ -224,8 +224,12 @@ tc-logview query -e fx-ci --type task-claimed \
# JSONL output, pipe to jq
tc-logview query -e fx-ci --type hook-fire --json | jq '.taskId'

# Raw GCP entries for debugging
tc-logview query -e fx-ci --type worker-stopped --raw --limit 5
# Raw GCP entries for debugging (one compact JSON object per line)
tc-logview query -e fx-ci --type worker-stopped --raw --limit 5 | jq '.jsonPayload.Fields'

# Multiple --where flags are ANDed; values may be quoted and contain commas
tc-logview query -e fx-ci --type monitor.apiMethod \
--where 'name="gcpCredentials"' --where 'statusCode="500"'

# Page through cached results
tc-logview query -e fx-ci --type worker-stopped \
Expand Down Expand Up @@ -299,15 +303,15 @@ tc-logview query --type worker-stopped
| `-e, --env` | Environment name | from `TASKCLUSTER_ROOT_URL` |
| `--type` | Log type (e.g. `worker-stopped`, `monitor.error`) | |
| `--service` | Filter by service (useful for shared types like `monitor.apiMethod`) | auto-detected if type is unique |
| `--where` | Field shorthand filter (e.g. `workerPoolId="proj-misc"`) | |
| `--where` | Field shorthand filter (e.g. `workerPoolId="proj-misc"`); repeatable, each value taken verbatim | |
| `--filter` | Raw GCP filter expression | |
| `--since` | Relative time window (`30m`, `2h`, `1d`) | `1h` |
| `--from` | Absolute start time (RFC3339) | |
| `--to` | Absolute end time (RFC3339) | |
| `--limit` | Max entries | `100` |
| `--offset` | Skip N entries (cached results only) | `0` |
| `--json` | JSONL output | |
| `--raw` | Full raw GCP entries | |
| `--raw` | Full raw GCP entries, one compact JSON object per line (JSONL) | |
| `--no-cache` | Skip result cache | |

## How it works
Expand All @@ -320,6 +324,14 @@ The tool builds GCP filters from three layers:
2. **Type** — `jsonPayload.Type` + `jsonPayload.serviceContext.service` (from `--type`, auto-narrowed)
3. **User** — `--where` (expanded to `jsonPayload.Fields.*`) and `--filter` (raw passthrough)

### Quotas and pagination

Cloud Logging limits read requests (default 60 per minute per project). tc-logview sizes each
page to the remaining `--limit` (max 1000 per request), so a query with `--limit 100` costs one
read request. If the quota is exceeded (`ResourceExhausted` / `RATE_LIMIT_EXCEEDED`), the request
is retried up to 4 more times with exponential backoff (10s, 20s, 40s, 60s), with a short notice
printed to stderr before each retry. Cached results (see below) cost no read requests.

### Caching

- **References** (`~/.cache/tc-logview/references/`) — Cached indefinitely, refresh with `tc-logview sync`
Expand Down
6 changes: 4 additions & 2 deletions cmd/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,17 @@ var queryCmd = &cobra.Command{
func init() {
queryCmd.Flags().StringVar(&queryType, "type", "", "log type (e.g. worker-stopped)")
queryCmd.Flags().StringVar(&queryService, "service", "", "filter by service (for shared types)")
queryCmd.Flags().StringSliceVar(&queryWhere, "where", nil, "field shorthand filter (e.g. workerPoolId=\"proj-misc\")")
// StringArray (not StringSlice): each --where is taken verbatim. StringSlice
// CSV-parses values, which rejects quotes (name="x") and splits on commas.
queryCmd.Flags().StringArrayVar(&queryWhere, "where", nil, "field shorthand filter, repeatable (e.g. workerPoolId=\"proj-misc\")")
queryCmd.Flags().StringVar(&queryFilter, "filter", "", "raw GCP filter expression")
queryCmd.Flags().StringVar(&querySince, "since", "", "relative time window (e.g. 2h, 30m, 1d)")
queryCmd.Flags().StringVar(&queryFrom, "from", "", "absolute start time (RFC3339)")
queryCmd.Flags().StringVar(&queryTo, "to", "", "absolute end time (RFC3339)")
queryCmd.Flags().IntVar(&queryLimit, "limit", 100, "max entries to return")
queryCmd.Flags().IntVar(&queryOffset, "offset", 0, "skip N entries (cached results only)")
queryCmd.Flags().BoolVar(&queryJSON, "json", false, "output as JSONL")
queryCmd.Flags().BoolVar(&queryRaw, "raw", false, "output full raw GCP entries")
queryCmd.Flags().BoolVar(&queryRaw, "raw", false, "output full raw GCP entries as JSONL (one compact object per line)")
queryCmd.Flags().BoolVar(&queryNoCache, "no-cache", false, "skip cache")
rootCmd.AddCommand(queryCmd)
}
Expand Down
42 changes: 42 additions & 0 deletions cmd/query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"slices"
"testing"

"github.com/spf13/pflag"
)

// TestQueryWhereFlagVerbatim guards against --where being CSV-parsed (as a
// StringSlice would): quoted values, unquoted values and values containing
// commas must all be passed through verbatim, one entry per flag.
func TestQueryWhereFlagVerbatim(t *testing.T) {
flag := queryCmd.Flags().Lookup("where")
prev := slices.Clone(queryWhere)
t.Cleanup(func() {
_ = flag.Value.(pflag.SliceValue).Replace(prev)
flag.Changed = false
})

args := []string{
`--where`, `name="gcpCredentials"`,
`--where`, `runId=1`,
`--where`, `reason="a,b"`,
`--where`, `pod=x,y`,
}
if err := queryCmd.Flags().Parse(args); err != nil {
t.Fatalf("parsing --where flags: %v", err)
}
want := []string{`name="gcpCredentials"`, `runId=1`, `reason="a,b"`, `pod=x,y`}
if !slices.Equal(queryWhere, want) {
t.Errorf("queryWhere = %q, want %q", queryWhere, want)
}
}

func TestParseWhereFieldNames(t *testing.T) {
got := parseWhereFieldNames([]string{`taskId="abc"`, `runId>=1`, `name="a,b"`})
want := []string{"taskId", "runId", "name"}
if !slices.Equal(got, want) {
t.Errorf("parseWhereFieldNames = %q, want %q", got, want)
}
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ go 1.26.0
require (
cloud.google.com/go/logging v1.13.2
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/tidwall/gjson v1.18.0
golang.org/x/oauth2 v0.35.0
google.golang.org/api v0.269.0
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
Expand All @@ -28,7 +30,6 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
Expand All @@ -45,5 +46,4 @@ require (
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.3 // indirect
)
20 changes: 20 additions & 0 deletions internal/filter/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,23 @@ func TestBuild_PresetFilterUnknownField(t *testing.T) {
t.Fatal("expected error for unknown field in preset context")
}
}

func TestBuild_WhereQuotedUnquotedAndCommaValues(t *testing.T) {
got, err := Build(Params{
Cluster: "c",
Where: []string{`name="gcpCredentials"`, `runId=1`, `message="a,b"`},
FieldNames: []string{"name", "runId", "message"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
`jsonPayload.Fields.name="gcpCredentials"`,
`jsonPayload.Fields.runId=1`,
`jsonPayload.Fields.message="a,b"`,
} {
if !strings.Contains(got, want) {
t.Errorf("filter missing %q, got: %s", want, got)
}
}
}
16 changes: 5 additions & 11 deletions internal/format/raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,17 @@ import (
"io"
)

// Raw formats log entries by pretty-printing each entry's Raw map as
// indented JSON, separated by newlines.
// Raw formats log entries as JSON Lines: each entry's full Raw map is written
// as one compact JSON object per line, suitable for piping to jq or other
// line-based tools.
type Raw struct{}

// Format writes each entry's Raw map as pretty-printed JSON (2-space indent)
// to w, with a blank line between entries.
// Format writes each entry's Raw map as a single-line JSON object to w.
func (r *Raw) Format(w io.Writer, entries []LogEntry, fieldOrder []string, total int) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")

for i, e := range entries {
if i > 0 {
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
}
for _, e := range entries {
if err := enc.Encode(e.Raw); err != nil {
return err
}
Expand Down
50 changes: 50 additions & 0 deletions internal/format/raw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package format

import (
"bytes"
"encoding/json"
"strings"
"testing"
)

func TestRaw_OneCompactObjectPerLine(t *testing.T) {
entries := []LogEntry{
{Raw: map[string]interface{}{
"timestamp": "2026-03-10T12:00:00Z",
"jsonPayload": map[string]interface{}{"reason": "FailedPreStopHook", "msg": "a\nb <x>"},
}},
{Raw: map[string]interface{}{"timestamp": "2026-03-10T12:00:01Z", "textPayload": "hello"}},
}

var buf bytes.Buffer
if err := (&Raw{}).Format(&buf, entries, nil, 2); err != nil {
t.Fatalf("Format: %v", err)
}

lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), buf.String())
}
for i, line := range lines {
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Errorf("line %d is not a JSON object: %v\n%s", i, err, line)
}
if strings.HasPrefix(line, " ") || strings.Contains(line, "\n") {
t.Errorf("line %d is not compact: %q", i, line)
}
}
if !strings.Contains(lines[0], "<x>") {
t.Errorf("HTML characters should not be escaped, got: %s", lines[0])
}
}

func TestRaw_Empty(t *testing.T) {
var buf bytes.Buffer
if err := (&Raw{}).Format(&buf, nil, nil, 0); err != nil {
t.Fatalf("Format: %v", err)
}
if buf.Len() != 0 {
t.Errorf("expected no output, got %q", buf.String())
}
}
Loading
Loading