Obsero ingestion Documentation
The contract GitHub

Google Cloud

Streams every request hitting a Cloud Load Balancing backend service to Obsero.

viewer -> Cloud CDN + external Application LB -> Cloud Logging -> Log Router sink -> Pub/Sub -> Cloud Run adapter -> POST /v1/events \-> dead-letter topic (replayable)

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

the whole integration
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-headers needed careful splitting on %0A before 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.userAgent and httpRequest.remoteIp are 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.

RoleFor
roles/editorload balancer, CDN, backend services, NEGs, Pub/Sub topics and subscriptions, Artifact Registry
roles/logging.configWriterthe 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.adminpubsub.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.admindeploying the adapter and setting its invoker policy
roles/iam.serviceAccountAdminthe 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:

ComponentCost
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 totalunder $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_rate under 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

Was this page helpful?

Reference implementations for streaming CDN request logs to Obsero — a starting point, not a product. Fork it and keep the parts that match your stack. Source on GitHub.

Measured numbers on these pages come from live deployments and are recorded in each cloud's NOTES.md. Confirm current cloud pricing before quoting any cost figure.