Google Cloud
Streams every request hitting a Cloud Load Balancing backend service to Obsero.
Everything runs in your own project. The adapter strips authorization,
cookie and set-cookie before anything leaves it.
#Read this first
Backend buckets produce no request logs
BackendBucket has no logConfig field at all in the
Compute v1 API, so a static site served straight from Cloud Storage through Cloud CDN emits nothing
to ship. Your origin has to be a backend service.
Apps on Cloud Run, GKE or a MIG already are one and are unaffected. A pure GCS origin has to change before it can be onboarded — which is why the demo site here serves its static pages from Cloud Run rather than a bucket.
#Try it without a GCP project
cd gcp
make test # adapter payload vs the contract
make event TOKEN=<tracking-id> DOMAIN=acme.com # POST one event, check the ID
Then, to watch it work end to end, the quickstart — or the make targets directly:
make site # site + LB + header logging only
make sample # confirm the headers reach Cloud Logging
make deploy # the rest: sink, Pub/Sub, adapter
make traffic N=40 # mock browser / AI-agent / crawler traffic
make check # score what actually reached the endpoint
make destroy # the load balancer bills at idle -- do this
The two-stage start is deliberate: make sample confirms the header allow-list behaves
before you build anything downstream of it.
#Wiring it into your own load balancer
module "obsero_ingestion" {
source = "./ingestion"
project_id = var.project_id
name_prefix = "acme-prod"
site_token = var.obsero_site_token
backend_service_name = google_compute_backend_service.site.name
}
Unlike the AWS module there is no ARN to attach — this one configures logging on the named
backend service directly. Better DX, at the cost of mutating a resource you own. It is reversible;
terraform destroy takes the allow-list back off.
#The headline win: named request headers on the cheap path
CloudFront forces a choice — pay ~$11/month for real-time logs to get the full header set, or
take standard logging and lose everything but User-Agent. GCP has no such trade.
BackendServiceLogConfig.loggingHttpRequestHeaders names the headers you want and they
arrive through the ordinary, cheap path:
from, # GPTBot, Googlebot
signature-agent, signature-input, signature, # Web Bot Auth
accept, accept-language, # browsers vs */* bots
sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile, # client hints
sec-fetch-dest # presence alone is the signal
The API caps the list at 10
An eleventh header is rejected outright. Ten is enough only because user-agent,
referer and the client IP arrive as first-class httpRequest fields whether
or not you name them — spending slots on those would waste them. The adapter merges both
sources.
So the question is never “can I add one more?” It is which one you are dropping.
sec-fetch-mode and sec-fetch-site were the two cut from the default
list.
It is an allow-list, not “all headers”, which is arguably the better default: the payload carries only what classification needs, so nothing incidental leaves your project.
#What the logged headers actually look like
The allow-list lands under jsonPayload.loggingHttpRequestHeaders, as an array
of pairs, with every value base64-encoded:
"loggingHttpRequestHeaders": [
{ "headerKey": "from", "headerValue": "Ym90QG9wZW5haS5jb20=" },
{ "headerKey": "signature-agent", "headerValue": "Imh0dHBzOi8vY2hhdGdwdC5jb20i" }
]
- The base64 is a feature. It is why Web Bot Auth signatures survive
byte-for-byte — quotes, commas and embedded colons included. CloudFront's percent-encoded
cs-headersneeded careful splitting on%0Abefore decoding, or a header value could forge an extra header. Nothing here can. - An absent header is simply missing from the array. There is no empty-string placeholder, so “not sent” and “sent empty” are indistinguishable.
httpRequest.userAgentandhttpRequest.remoteIpare populated regardless, confirming those slots are free.
#Permissions — roles/editor is not enough
roles/editor deliberately excludes log-routing configuration and resource-level
setIamPolicy, which are the core of this pipeline. Check these before anyone runs
apply.
| Role | For |
|---|---|
roles/editor | load balancer, CDN, backend services, NEGs, Pub/Sub topics and subscriptions, Artifact Registry |
roles/logging.configWriter | the Log Router sink, and the _Default exclusion that stops you paying to store logs you already routed. Not implied by Editor — apply fails at the sink. |
roles/pubsub.admin | pubsub.topics.setIamPolicy, to grant the sink's writer identity publisher on the topic. roles/pubsub.editor does not cover it — without it the sink is created but silently publishes nothing. |
roles/run.admin | deploying the adapter and setting its invoker policy |
roles/iam.serviceAccountAdmin | the two service accounts the module creates |
PROJECT=your-project
MEMBER=user:you@example.com
for ROLE in logging.configWriter pubsub.admin run.admin iam.serviceAccountAdmin; do
gcloud projects add-iam-policy-binding "$PROJECT" \
--member="$MEMBER" --role="roles/$ROLE"
done
Check it directly rather than inferring from role names. There is no
gcloud projects test-iam-permissions subcommand, so the REST call is the way — an
empty permissions array back means none are granted:
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"permissions":[
"logging.sinks.create","logging.sinks.update",
"logging.exclusions.create","logging.buckets.update",
"pubsub.topics.setIamPolicy"
]}' \
"https://cloudresourcemanager.googleapis.com/v1/projects/$PROJECT:testIamPermissions"
#APIs
setup.sh checks these and offers to enable them:
gcloud services enable \
compute.googleapis.com run.googleapis.com pubsub.googleapis.com \
logging.googleapis.com artifactregistry.googleapis.com iam.googleapis.com \
--project="$PROJECT"
Only project-level IAM is visible locally; org-level constraints surface at apply time. The two
most likely to bite are a constraint blocking external HTTP(S) load balancers, and
constraints/iam.disableServiceAccountKeyCreation.
#The Terraform trap that fails silently
Neither hashicorp/google nor google-beta (v7) models
loggingHttpRequestHeaders — log_config exposes only
enable, optional_fields, optional_mode and
sample_rate. The module therefore shells out to gcloud from a
terraform_data + local-exec.
The allow-list survives Terraform only if it is re-asserted
The first version keyed the shell-out on a hash of the inputs and paired it with a destroy-time
provisioner that removed the list again. On the next full terraform apply that destroy
provisioner fired during a replacement and wrote back a logConfig with no
loggingHttpRequestHeaders.
Logs kept flowing. They simply arrived with nothing to classify on, and
terraform plan said “No changes” because Terraform cannot see a field it
does not model.
The fix is two resources rather than one. terraform_data.request_logging triggers on
timestamp(), so every apply re-installs the list and any drift heals itself. Removal
moved to terraform_data.request_logging_cleanup, whose triggers never change, so it
fires only on a genuine terraform destroy.
The cost is one unavoidable change in every plan. Do not “clean that up” — the trade is an invisible failure of the pipeline's whole purpose against a noisy plan.
#Before you trust the numbers
Config changes take minutes to reach the edge
After re-installing the allow-list, requests sent ~60 seconds later were still logged with no headers. The same probe at ~10 minutes carried all of them. Budget several minutes before trusting a run taken straight after an apply, and treat a sudden run of headerless events after a deploy as propagation rather than breakage.
Excluding from _Default really does hide the logs
Once exclude_from_default_bucket takes effect, gcloud logging read
returns nothing for the load balancer — the entries are routed but never stored. That is the
intended saving, and it means Pub/Sub is the only place to read raw entries.
make sample reads storage, so it goes quiet once the exclusion lands; pull the verify
subscription instead.
Delivery is at-least-once
A push that fails after the adapter already forwarded some of its events is retried whole. After
max_delivery_attempts the message parks on the dead-letter topic, where it stays queued
and replayable.
The adapter image is built locally, not in Cloud Build
terraform apply builds and pushes a container unless you set
adapter_image. That needs docker and an authenticated gcloud
on the machine running apply. In a default-configured project Cloud Build runs as the default
compute service account, which cannot read its own staging bucket — a local build needs only a
configured credential helper, and is faster besides.
#Cost
Per 1M requests/month, ~1 KB per log entry:
| Component | Cost |
|---|---|
| Pub/Sub | ~$0.04 |
| Cloud Run adapter | $0 — free tier, scales to zero |
| Cloud Logging routing | $0 — routing to a sink is not charged |
| Cloud Logging storage | $0 with exclude_from_default_bucket |
| Pipeline total | under $1/month |
The load balancer itself is not free — roughly $18/month for the forwarding rule plus per-GB processing, whatever the traffic. That does not matter to a customer who already runs one; it matters a lot for a demo rig that exists only to make traffic.
Confirm before quoting
Check current forwarding-rule and data-processing pricing before quoting any of these numbers to anyone.
#Tearing it down
make destroy # terraform destroy, then check nothing is left standing
make leftovers # just tell me what is still deployed
Do this between runs. The forwarding rule bills at idle whether or not traffic is flowing, and it is the single most expensive thing in the repo.
terraform destroy needs project_id, which has no default. Keep it in
site/terraform/terraform.tfvars; the script checks for it up front rather than letting
Terraform stall on a prompt. destroy.sh --sweep deletes leftovers rather than only
reporting them — useful if a destroy half-finishes, since a Cloud Run service or backend service
left behind will not show up in any later plan.
#Still unverified
Do not assert these:
- per-header value truncation in
loggingHttpRequestHeaders— the count limit is settled at 10, and nothing in a 40-request run came back truncated - whether request logging is sampled independently of
sample_rateunder load, the way CloudFront real-time logs turned out to be best-effort. Three runs of 40 delivered 39, 40 and 40 — no evidence of loss, far too small a sample to call it exact - end-to-end latency beyond the observed ~30–60s
Code on this page
gcp/ingestion/main.tfthe pipeline: sink, Pub/Sub, Cloud Run adaptergcp/ingestion/variables.tfevery module input, with its validationgcp/ingestion/adapter/index.mjsthe Cloud Run adaptergcp/site/terraform/main.tfthe demo stack: mock site plus one module blockgcp/PERMISSIONS.mdthe IAM roles, and why Editor is not enoughgcp/NOTES.mdbehaviour measured on live deployments