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.
- 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
Throwableas a Cloud Error Reporting event (@typeReportedErrorEvent,serviceContext,context.reportLocation, and a Java stack trace inmessagefor grouping). - OpenTelemetry optional: trace/span ids are read from the signal's
:otel/contextreflectively, so the library works with or without the OTel API on the classpath. - Pluggable JSON encoder, defaulting to lightweight charred.
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"}(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!).
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}"}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"}):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 oneJSONWriter. Trade-offs: writes only to a stdoutWriter(no:encode-fnswap), 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 :blockto 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.
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 aStringper 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
:asyncslightly 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
:asyncwhen log volume is high and stdout can stall (pipes, busy collectors); choose:streamfor the lowest-allocation synchronous path; keep the default:stringfor simplicity and full:encode-fnflexibility.
| 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). |
: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.:datakeys:user/:http-requestpopulate the Error Reportingcontext.
(require '[jsonista.core :as j])
(tg/install! {:encode-fn j/write-value-as-string})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.
clojure -P -A:otel:test # fetch deps
clojure -X:test # run tests