starlette-gcp-logging
Structured JSON logging for Starlette services running on Google Cloud Run (or any GCP compute platform).
Provides two components that work together:
GCPFormatter— alogging.Formatterthat serialises every log record as a single-line JSON object understood by Cloud Logging, including severity mapping, source location, and trace/span correlation.GCPRequestLoggingMiddleware— a Starlette middleware that emits one structured log entry per request/response (withhttpRequestmetadata) and propagates trace context to every logger used inside that request viacontextvars— no manual plumbing needed.
Installation
pip install starlette-gcp-logging
Quick start
import logging
from starlette.applications import Starlette
import starlette_gcp_logging
# Wire up the formatter once at startup — all loggers inherit it.
handler = logging.StreamHandler()
handler.setFormatter(
starlette_gcp_logging.GCPFormatter()
) # project_id auto-detected on GCP
logging.basicConfig(handlers=[handler], level=logging.INFO)
app = Starlette(...)
app.add_middleware(starlette_gcp_logging.GCPRequestLoggingMiddleware)
With uvicorn:
uvicorn myapp:app --log-config /dev/null # let GCPFormatter own the output
Configuration
GCPFormatter(project_id="", *, labels=None)
| Parameter | Description |
|---|---|
project_id |
GCP project ID used to build the full trace resource name projects/<id>/traces/<trace_id>. When omitted (the default) it is fetched automatically from the GCP instance metadata server on the first log call and cached for the lifetime of the process. Outside GCP the trace is still written — just without the project prefix. |
labels |
Optional mapping of custom GCP label name → the ContextVar its value should be read from. See Custom labels below. |
Log record fields emitted
| JSON key | Value |
|---|---|
severity |
DEBUG / INFO / WARNING / ERROR / CRITICAL |
message |
Formatted log message |
time |
RFC 3339 UTC timestamp |
logging.googleapis.com/sourceLocation |
file, line, function |
logging.googleapis.com/trace |
Full trace resource name (when a trace ID is present) |
logging.googleapis.com/spanId |
16-char hex span ID |
logging.googleapis.com/traceSampled |
Boolean sampling flag |
exception |
Formatted traceback (when exc_info is set) |
@type |
GCP Error Reporting type URI (when exc_info is set) |
| (extra keys) | Any fields passed via extra={...} to the logger |
GCPRequestLoggingMiddleware(app, *, project_id="", logger_name=..., default_level=logging.INFO)
| Parameter | Description |
|---|---|
project_id |
Same as GCPFormatter. Auto-detected when omitted. |
logger_name |
Logger to write request entries to. Defaults to starlette_gcp_logging.middleware. |
default_level |
Log level for 1xx/2xx/3xx responses. 4xx → WARNING; 5xx → ERROR. |
Custom labels
Application code often stores per-request data (tenant ID, feature flag,
API key ID, ...) in its own contextvars.ContextVar. Pass a labels mapping
to GCPFormatter to surface any of those as GCP labels on every log entry
emitted while the variable holds a value:
import contextvars
import logging
import starlette_gcp_logging
tenant_id: contextvars.ContextVar[str] = contextvars.ContextVar("tenant_id", default="")
handler = logging.StreamHandler()
handler.setFormatter(
starlette_gcp_logging.GCPFormatter(labels={"tenant_id": tenant_id})
)
logging.basicConfig(handlers=[handler], level=logging.INFO)
Somewhere earlier in the middleware stack (or in a dependency), set the variable for the duration of the request:
tok = tenant_id.set("acme-corp")
try:
...
finally:
tenant_id.reset(tok)
Every log entry emitted while tenant_id holds a value (including falsy
values like False, 0, or "") will include:
jsonPayload."logging.googleapis.com/labels"."tenant_id" == "acme-corp"
A label is only omitted when its ContextVar has not been .set() in the
current context (i.e. it is unset), matching the behavior of the built-in
authenticated_user_email and starlette.dev/route labels. This lets you
distinguish an explicitly-set falsy value (e.g. a False feature flag) from
an unset variable — unlike a truthiness check, None is the only value
treated as "unset".
Trace context extraction
The middleware reads incoming trace headers in this priority order:
traceparent(W3C / OpenTelemetry) — used when an upstream service propagates its own trace. Cloud Run appends its own span to this header, so it is always preferred when present.X-Cloud-Trace-Context— GCP's own header, injected when there is no upstreamtraceparent.
The extracted trace ID and span ID are stored in contextvars for the
duration of the request, so every logger in the call stack automatically picks
them up without any explicit propagation.
Accessing trace context manually
from starlette_gcp_logging import formatter
# Inside a request handler:
print(formatter.request_trace.get()) # "projects/my-project/traces/abc123..."
print(formatter.request_span.get()) # "00f067aa0ba902b7"
Project ID auto-detection
from starlette_gcp_logging import _metadata
project = _metadata.get_project_id() # fetches from metadata server, then cached
Outside GCP (local dev, CI) the metadata request times out after 1 second and an empty string is returned — logging continues to work, just without the project prefix in the trace resource name.
Example GCP alerts grouped by Starlette route
The middleware writes the route template to:
jsonPayload."logging.googleapis.com/labels"."starlette.dev/route"
Use that field as a log-based metric label so alerts are grouped per route
template (for example, "/items/{item_id}").
1) 4xx error-rate alert by route
Create two log-based counter metrics with the same label extractor:
logging.googleapis.com/user/starlette_requests_totallogging.googleapis.com/user/starlette_requests_4xx
Filter / extractor examples:
# total requests
resource.type="cloud_run_revision"
jsonPayload.httpRequest.status>=100
jsonPayload.httpRequest.status<600
jsonPayload."logging.googleapis.com/labels"."starlette.dev/route"!=""
labelExtractors.route=EXTRACT(jsonPayload."logging.googleapis.com/labels"."starlette.dev/route")
# 4xx requests
resource.type="cloud_run_revision"
jsonPayload.httpRequest.status>=400
jsonPayload.httpRequest.status<500
jsonPayload."logging.googleapis.com/labels"."starlette.dev/route"!=""
labelExtractors.route=EXTRACT(jsonPayload."logging.googleapis.com/labels"."starlette.dev/route")
Alerting condition (MQL example, threshold 5% over 5m):
fetch logging.googleapis.com/user/starlette_requests_4xx
| align rate(5m)
| group_by [metric.route], [err: sum(value.rate)]
| join
(fetch logging.googleapis.com/user/starlette_requests_total
| align rate(5m)
| group_by [metric.route], [all: sum(value.rate)])
| value [error_rate: err / all]
| condition error_rate > 0.05 '1'
2) 5xx error-rate alert by route
Create a logging.googleapis.com/user/starlette_requests_5xx counter metric:
resource.type="cloud_run_revision"
jsonPayload.httpRequest.status>=500
jsonPayload.httpRequest.status<600
jsonPayload."logging.googleapis.com/labels"."starlette.dev/route"!=""
labelExtractors.route=EXTRACT(jsonPayload."logging.googleapis.com/labels"."starlette.dev/route")
Alerting condition (MQL example, threshold 1% over 5m):
fetch logging.googleapis.com/user/starlette_requests_5xx
| align rate(5m)
| group_by [metric.route], [err: sum(value.rate)]
| join
(fetch logging.googleapis.com/user/starlette_requests_total
| align rate(5m)
| group_by [metric.route], [all: sum(value.rate)])
| value [error_rate: err / all]
| condition error_rate > 0.01 '1'
3) Successful-request latency alert by route
Create a log-based distribution metric
logging.googleapis.com/user/starlette_success_latency_seconds:
resource.type="cloud_run_revision"
jsonPayload.httpRequest.status>=200
jsonPayload.httpRequest.status<400
jsonPayload.httpRequest.latency=~".*s"
jsonPayload."logging.googleapis.com/labels"."starlette.dev/route"!=""
labelExtractors.route=EXTRACT(jsonPayload."logging.googleapis.com/labels"."starlette.dev/route")
valueExtractor=REGEXP_EXTRACT(jsonPayload.httpRequest.latency, "^([0-9.]+)s$")
Alerting condition (MQL example, p95 latency > 1s over 5m):
fetch logging.googleapis.com/user/starlette_success_latency_seconds
| group_by 5m, [metric.route], [p95: percentile(value.distribution, 95)]
| condition p95 > 1
Metadata
Release files for starlette-gcp-logging 0.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| starlette_gcp_logging-0.0.2-py3-none-any.whl | Python 3 | none | any | Details for starlette_gcp_logging-0.0.2-py3-none-any.whl |
Release files / starlette_gcp_logging-0.0.2-py3-none-any.whl
| Download URL | starlette_gcp_logging-0.0.2-py3-none-any.whl |
|---|---|
| Size | 13.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
37867038b788ab63325a551c327fb5c3967bf772987553249be2f7a60b4b44b7
|
|
BLAKE2b-256 checksum How to use checksums |
9948fa393b9ac2f44a24c2bb83a9751d44d86f73c90e9e6c1baa2dcc9ae32f01
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logSecurity for starlette-gcp-logging 0.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
1 file in this release has been uploaded with provenance metadata.
Attestation for starlette_gcp_logging-0.0.2-py3-none-any.whl
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.