Skip to content

dspiteself/telemere-google

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

telemere-google

A Telemere handler that emits structured JSON compatible with Google Cloud Logging, including Cloud Error Reporting and OpenTelemetry trace/span correlation.

It writes single-line JSON to stdout — the format the Cloud Logging agent, Cloud Run, and GKE automatically parse and promote into native LogEntry fields. No google-cloud-logging Java client or credentials required.

Features

  • Maps Telemere levels to Cloud Logging LogSeverity.
  • Promotes the special fields Cloud Logging recognises: severity, message, timestamp, logging.googleapis.com/trace, .../spanId, .../trace_sampled, .../sourceLocation, .../insertId, .../labels.
  • Shapes any signal carrying a Throwable as a Cloud Error Reporting event (@type ReportedErrorEvent, serviceContext, context.reportLocation, and a Java stack trace in message for grouping).
  • OpenTelemetry optional: trace/span ids are read from the signal's :otel/context reflectively, so the library works with or without the OTel API on the classpath.
  • Pluggable JSON encoder, defaulting to lightweight charred.

Install

deps.edn:

{:deps {io.github.you/telemere-google {:local/root "."}}}

For Cloud Trace correlation, also put the OpenTelemetry API on the classpath (the library uses it only when present):

io.opentelemetry/opentelemetry-api {:mvn/version "1.60.1"}

Quick start

(require '[taoensso.telemere :as tel]
         '[telemere-google.core :as tg])

(tg/install! {:project-id "my-gcp-project"
              :service    "my-service"
              :version    "1.0.0"})

(tel/log!   :info "service started")
(tel/error! (ex-info "payment failed" {:order 42}))

install! registers a Telemere console handler (id :telemere-google/google) that prints GCP JSON. Remove it with (tg/uninstall!).

Example output

A normal log:

{"user-id":7,"severity":"INFO","message":"service started",
 "timestamp":"2026-06-15T12:00:00Z",
 "logging.googleapis.com/sourceLocation":{"file":"my.app","line":"12","function":"my.app"}}

An error (Error Reporting event):

{"@type":"type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent",
 "severity":"ERROR",
 "message":"clojure.lang.ExceptionInfo: payment failed ...\n\tat ...",
 "serviceContext":{"service":"my-service","version":"1.0.0"},
 "context":{"reportLocation":{"filePath":"my.app","lineNumber":99,"functionName":"my.app"}},
 "stack_trace":"clojure.lang.ExceptionInfo: payment failed ...",
 "exception":"clojure.lang.ExceptionInfo: payment failed {:order 42}"}

Handler variants & performance

install! takes a :mode:

mode how it writes
:string (default) builds a JSON String per signal via Telemere's console handler.
:stream streams JSON straight to a buffered stdout Writer through a reused charred JSONWriter — no intermediate String. Flushes per line.
:async log4j2-style: the app thread only enqueues; a background thread formats and streams, flushing once per drained batch.
(tg/install! {:mode :async :project-id "my-proj" :service "svc"})

When to use which mode

  • :string (default) — use unless you have a measured reason not to. It's the simplest, integrates with Telemere's console handler, and is the only mode that honors a custom :encode-fn (jsonista/cheshire). Fine for typical request/job logging where log volume isn't a hotspot. Telemere's own async dispatch already keeps it off the critical path for most apps.
  • :stream — reach for it when logging is hot enough that per-line garbage matters (tight loops, high-QPS services, GC-sensitive latency tails). It cuts allocation ~5.5× on small lines by reusing one JSONWriter. Trade-offs: writes only to a stdout Writer (no :encode-fn swap), and it flushes per line, so the app thread still pays the write+flush cost. Best when the sink (stdout to a pipe or collector) is fast.
  • :async — use when log volume is high and the sink can stall (slow pipes, busy log collectors, bursty traffic), or when you must keep flush/IO cost off the request thread entirely. The app thread only enqueues (~50 ns), and a background thread batches flushes. Costs: an extra thread, a bounded queue, and — by default — dropping under sustained overload (set :on-full :block to apply backpressure instead, or raise :queue-size). Don't pick it just for raw throughput against a fast sink: there the queue handoff makes it slightly slower than :stream.

Rule of thumb: default to :string; switch to :stream to cut allocation on a fast sink; switch to :async to decouple the app thread from a slow/bursty sink.

Benchmark results

clojure -M:bench (JDK 25, formatting to a discarding sink to isolate cost; allocation via HotSpot's per-thread counter):

INFO signal (small line)            time        allocation
  build GCP map only              ~800 ns/op      2.4 KB/op
  :string  (map -> String)        ~3050 ns/op    21.5 KB/op
  :stream  (reused JSONWriter)    ~1820 ns/op     3.9 KB/op   <- 5.5x less garbage

ERROR signal (large, full stack trace)
  build GCP map only            ~10300 ns/op     54.9 KB/op
  :string                       ~20900 ns/op     97.0 KB/op
  :stream                       ~17400 ns/op     61.5 KB/op

Takeaways:

  • Streaming is a real allocation win. Reusing one JSONWriter (vs building a String per line) cuts per-line garbage ~5.5x for small entries (21.5 KB → 3.9 KB) and is ~40% faster. The win shrinks in % terms for big error lines because building the GCP map + the Java stack-trace string dominates there — not the JSON encoding.

  • Async only helps against slow I/O. Against a fast/null sink, the queue handoff makes :async slightly slower than :stream (formatting, not I/O, is the bottleneck). But with a slow flushing sink (20 µs/flush, 100k signals) the app thread is freed almost entirely:

    sync  :stream   app thread blocked ~7300 ms
    async :async    app thread free       ~5 ms   (drained in background ~230 ms)
    

    So choose :async when log volume is high and stdout can stall (pipes, busy collectors); choose :stream for the lowest-allocation synchronous path; keep the default :string for simplicity and full :encode-fn flexibility.

Options (install! / signal->gcp-map)

Option Purpose
:project-id GCP project id; else env GOOGLE_CLOUD_PROJECT / GCP_PROJECT / GCLOUD_PROJECT.
:service/:version Error Reporting serviceContext.
:labels static map merged into logging.googleapis.com/labels.
:encode-fn custom JSON encoder (1-arg map→string); default charred.
:error-min-level minimum level to shape as Error Reporting (default :error).
:handler-id Telemere handler id (default :telemere-google/google).

Per-signal extras

  • :gcp/labels — map merged into the entry's labels.
  • :otel/trace-id + :otel/span-id (+ :otel/trace-sampled) — supply trace correlation explicitly without OpenTelemetry on the classpath.
  • :data keys :user / :http-request populate the Error Reporting context.

Swapping the JSON encoder

(require '[jsonista.core :as j])
(tg/install! {:encode-fn j/write-value-as-string})

OpenTelemetry

When OTel is present and a signal is produced inside an active span, the trace is emitted as projects/{project-id}/traces/{traceHex} (Cloud Trace's expected form) with the span hex and sampled flag — mirroring the approach in breeze.mast.telemetry. Without OTel, these fields are simply omitted.

Development

clojure -P -A:otel:test   # fetch deps
clojure -X:test           # run tests

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors