Skip to content

Ingest from Google Cloud

You will learn how to get Google Cloud logs, alerting incidents, and metrics into Cordo.

GCP splits the job across Cloud Logging and Cloud Monitoring, and both route through Pub/Sub — which the OpenTelemetry Collector reads natively. So unlike AWS, logs and alerts need no function code at all: one collector covers both. Metrics are the exception; there is no managed push path.

Before you start

  1. Create a stream — Create and manage streams.
  2. Mint a stream API key (lax_…) with the otlp scope — Manage stream API keys.
  3. Have a host or runtime for the collector. A small GCE instance, a GKE Deployment, or Cloud Run all work. It needs outbound HTTPS and, for pulling subscriptions, roles/pubsub.subscriber on the subscriptions below.

What Google Cloud can push

Signal How it leaves GCP Typical target
Logs A Log Router sink Pub/Sub topic, Cloud Storage, BigQuery
Alerts A notification channel on an alerting policy Pub/Sub topic, email, webhook, PagerDuty, Slack
Metrics Not pushed — timeSeries.list is pull-only Your own poller

Two things worth noting:

  • Cloud Monitoring has a native pubsub notification channel type. Alerts and logs therefore share one transport, and one collector consumes both.
  • Sinks are per-resource. A project sink only sees that project's logs; folder and organisation sinks are aggregated and see their children. Create the sink at the highest resource whose logs you want.

Why nothing lands directly in Cordo

Cordo authenticates ingest in exactly two ways:

Ingest path Auth
POST /v1/streams/{id}/ingest (JSON) X-API-Key header
POST /v1/streams/{id}/ingest/text (plain text) X-API-Key header
POST /v1/otlp/v1/logs (OTLP Logs) X-API-Key header, or Authorization: Bearer
POST /_bulk (OpenSearch-compatible) HTTP Basic, password = stream API key

Against that, every direct GCP push mechanism fails:

Mechanism What it sends Result
webhook_tokenauth channel Token as a query-string parameter Rejected — Cordo has no query-string auth
webhook_basicauth channel HTTP Basic Rejected — Cordo's only Basic endpoint is _bulk, which expects NDJSON bulk documents, not an incident body
Pub/Sub push subscription OIDC Authorization: Bearer <JWT> Rejected — the key must be lax_…
Collector pulling Pub/Sub Headers you configure Works

Webhooks are also only allowed to reach public endpoints, and Pub/Sub push has no arbitrary-header escape hatch: PushConfig.attributes accepts only x-goog-version. The collector, pulling with an X-API-Key you control, is the supported path.

OTLP Logs only

Cordo accepts OTLP Logs at /v1/otlp/v1/logs. Do not point a metrics or traces pipeline at the same exporter — those requests fail and the collector retries, filling its own logs with errors.

Logs: Log Router sink → Pub/Sub → collector

1. Create the topic and subscription

gcloud pubsub topics create cordo-logs
gcloud pubsub subscriptions create cordo-logs-sub \
  --topic=cordo-logs \
  --ack-deadline=60

Pull, not push. Pub/Sub retains undelivered messages for 7 days by default, so a collector restart backfills instead of dropping.

2. Create the sink

gcloud logging sinks create cordo-logs-sink \
  pubsub.googleapis.com/projects/my-project/topics/cordo-logs \
  --log-filter='resource.type="cloud_run_revision" AND severity>=INFO'

Then grant the writer identity the sink prints on creation:

gcloud pubsub topics add-iam-policy-binding cordo-logs \
  --member="serviceAccount:$(gcloud logging sinks describe cordo-logs-sink \
      --format='value(writerIdentity)')" \
  --role=roles/pubsub.publisher

Each log entry is published as one Pub/Sub message containing the JSON-encoded LogEntry.

3. Configure the collector

extensions:
  google_cloud_logentry_encoding:
    handle_json_payload_as: json
    handle_proto_payload_as: json

receivers:
  googlecloudpubsub/cloud_logging:
    project: my-project
    subscription: projects/my-project/subscriptions/cordo-logs-sub
    encoding: google_cloud_logentry_encoding

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  # See "Set a service name" below — without this, patterns lose the service.
  transform/gcp_service:
    error_mode: ignore
    log_statements:
      - context: resource
        statements:
          - set(attributes["service.name"], attributes["gcp.label.service_name"]) where attributes["service.name"] == nil and attributes["gcp.label.service_name"] != nil
          - set(attributes["service.name"], attributes["gcp.resource_type"]) where attributes["service.name"] == nil and attributes["gcp.resource_type"] != nil

  batch:
    timeout: 5s
    send_batch_size: 5000

exporters:
  otlphttp/cordo:
    endpoint: https://<your-cordo-host>/v1/otlp
    headers:
      X-API-Key: "lax_<prefix>_<secret>"
    compression: gzip
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s

service:
  extensions: [google_cloud_logentry_encoding]
  pipelines:
    logs/logging:
      receivers: [googlecloudpubsub/cloud_logging]
      processors: [memory_limiter, transform/gcp_service, batch]
      exporters: [otlphttp/cordo]

Run it:

otelcol-contrib --config /etc/otelcol-contrib/gcp.yaml

The encoding extension was renamed three times

encoding: cloud_logging was built in until v0.132, then removed in favour of the googlecloudlogentry_encoding extension, renamed to google_cloud_logentry_encoding at v0.148 (the old name returned as a deprecated alias at v0.151). The config above is verified against v0.164. If you pin an older collector, match the name or the receiver fails to decode.

Set a service name

This is the one GCP-specific trap, and it is easy to miss because everything looks fine — events arrive, messages are readable, and only the service attribution is wrong.

The encoding extension does not emit service.name. A Cloud Run entry comes through as:

"resource": {
  "gcp.project": "my-project",
  "gcp.resource_type": "cloud_run_revision",
  "gcp.label.service_name": "checkout",
  "gcp.label.revision_name": "checkout-00042"
}

Cordo's service discovery looks for service.name, service, service_name, app, application, svc, and source — none of which match gcp.label.service_name. Without the transform above, every event has an empty service: Patterns shows a blank service mix, Findings cannot scope by service, and the ignored-services setting has nothing to match on.

The transform processor copies the useful label onto service.name, falling back to gcp.resource_type. Verified output for the entry above:

"resource": {
  "gcp.project": "my-project",
  "gcp.resource_type": "cloud_run_revision",
  "gcp.label.service_name": "checkout",
  "gcp.label.revision_name": "checkout-00042",
  "service.name": "checkout"
}

Severity needs no mapping

Cloud Logging severities map straight onto Cordo's normaliser, including the ones people usually forget — NOTICE becomes info, and CRITICAL, ALERT, and EMERGENCY all become error:

Cloud Logging Cordo level
DEBUG debug
INFO, NOTICE info
WARNING warn
ERROR, CRITICAL, ALERT, EMERGENCY error
DEFAULT other (no level)

Structured jsonPayload fields are promoted to top level, so ms and route in the example become queryable fields rather than being trapped in the body. Event time comes from the LogEntry timestamp, so a drained backlog keeps its original ordering.

Alerts: alerting policy → Pub/Sub → collector

1. Create a Pub/Sub notification channel

gcloud pubsub topics create cordo-alerts
gcloud pubsub subscriptions create cordo-alerts-sub --topic=cordo-alerts

gcloud beta monitoring channels create \
  --display-name="Cordo" \
  --type=pubsub \
  --channel-labels=topic=projects/my-project/topics/cordo-alerts

Then attach that channel to each alerting policy you want in Cordo. Incidents arrive as JSON with state (open / acknowledged / closed), policy_name, severity, summary, and started_at.

2. Add the alerts pipeline

Incidents need two things the collector must do, because Cordo will not do them for you: lift the incident JSON into queryable attributes, and turn it into a readable message. A string body containing JSON is not promoted to top level on the OTLP path — it stays nested under body, and an event whose only body is that JSON string produces no log pattern at all.

extensions:
  text_encoding:
    encoding: utf8
    unmarshaling_separator: "\r?\n"

receivers:
  googlecloudpubsub/alerts:
    project: my-project
    subscription: projects/my-project/subscriptions/cordo-alerts-sub
    encoding: text_encoding

processors:
  transform/alerts:
    error_mode: ignore
    log_statements:
      # Resource context, not log context — see the note below.
      - context: resource
        statements:
          - set(attributes["service.name"], "gcp-alerts")
      # Lift the incident JSON into queryable attributes.
      - context: log
        statements:
          - merge_maps(attributes, ParseJSON(body), "insert") where IsString(body) and IsMatch(body, "^\\{")
          - set(severity_text, "ERROR") where attributes["state"] == "open"
          - 'set(body, Concat([attributes["policy_name"], " ", attributes["state"], ": ", attributes["summary"]], "")) where attributes["policy_name"] != nil'

service:
  extensions: [text_encoding]
  pipelines:
    logs/alerts:
      receivers: [googlecloudpubsub/alerts]
      processors: [transform/alerts, batch]
      exporters: [otlphttp/cordo]

Verified output for an open incident — policy_name, state, severity, and summary are now attributes; the body is a readable message; the level is error:

{
  "attributes": {
    "incident_id": "0abc",
    "policy_name": "checkout-5xx",
    "severity": "Critical",
    "state": "open",
    "summary": "5xx above threshold"
  },
  "body": "checkout-5xx open: 5xx above threshold",
  "resource": {"service.name": "gcp-alerts"},
  "severity": "ERROR"
}

Use context: resource for service.name

OTTL's context: log writes to the log record's attributes, which is not where service is read from. With context: log, the service silently comes out empty. context: resource is what puts it where Cordo looks. Both were tested; only the resource context works.

Cordo is not an incident receiver

Cordo's notification channels — webhook, email, Slack, PagerDuty — are all outbound. Nothing can POST an incident into Cordo and have Cordo relay it to PagerDuty. Incidents arrive as events; Cordo's own monitors decide what is worth paging on. If you want to keep GCP paging you, leave the existing channel on the policy and add Cordo as a second one.

Metrics: pull only

There is no GCP equivalent to CloudWatch Metric Streams. Cloud Monitoring has no managed way to stream datapoints to an HTTP endpoint; timeSeries.list is the only exit, so polling is the only option.

The collector ships a googlecloudmonitoring receiver that polls for you:

receivers:
  googlecloudmonitoring:
    project_id: my-project
    collection_interval: 2m
    metrics_list:
      # Note the American spelling: GCP uses "utilization", not "utilisation".
      - metric_name: compute.googleapis.com/instance/cpu/utilization

It needs monitoring.metricDescriptors.list and monitoring.timeSeries.list.

That receiver is useless here on its own

It emits OTel metrics, and Cordo accepts logs only. Pointing it at the Cordo exporter produces nothing but retry errors. Either flatten each datapoint into a JSON event the way the CloudWatch guide does, or skip Cloud Monitoring metrics and record Cordo metrics from the logs you already ship — count by status, p95 of ms, an error rate. Those are usually the signals you actually alert on, and they need no extra plumbing.

Budgets

Limit Value
Native JSON / plain-text body 1 MiB per request
OTLP Logs body 10 MiB per request
Events per stream 12,000 per minute (default; configurable)
Events per API key 12,000 per minute (default)
Bytes per stream 64 MiB per minute (default)

send_batch_size: 5000 with gzip keeps a batch well inside the OTLP limit. If you raise it, watch the bytes: a 10 MiB decompressed body is the hard ceiling, and the collector does not split a batch for you.

Rejected requests are recorded as an Ingest refused reason on the stream, so check that before assuming the collector is broken.

Verify

  1. Collector is exporting. The built-in metrics endpoint shows otelcol_exporter_sent_log_records increasing and otelcol_exporter_send_failed_log_records flat:
curl -s http://127.0.0.1:8888/metrics \
  | grep -E 'otelcol_exporter_(sent|send_failed)_log_records'
  1. Events arrive. Open the stream's Events page. A Cloud Run log line should be one event with @timestamp matching the LogEntry timestamp.

  2. Service is set. This is the check that catches the trap above. Open Patterns and confirm rows show the service name (checkout), not a blank. If it is blank, the transform/gcp_service processor is missing or did not match — gcp.label.service_name is not being copied.

  3. Fields are learned. Open Fields. Structured jsonPayload keys such as ms and route should appear, alongside severity and service.

  4. Run a query. level = error should filter correctly, and service = checkout should scope to one service.

Next