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
- Create a stream — Create and manage streams.
- Mint a stream API key (
lax_…) with theotlpscope — Manage stream API keys. - 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.subscriberon 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
pubsubnotification 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
- Collector is exporting. The built-in metrics endpoint shows
otelcol_exporter_sent_log_recordsincreasing andotelcol_exporter_send_failed_log_recordsflat:
curl -s http://127.0.0.1:8888/metrics \
| grep -E 'otelcol_exporter_(sent|send_failed)_log_records'
-
Events arrive. Open the stream's Events page. A Cloud Run log line should be one event with
@timestampmatching theLogEntrytimestamp. -
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, thetransform/gcp_serviceprocessor is missing or did not match —gcp.label.service_nameis not being copied. -
Fields are learned. Open Fields. Structured
jsonPayloadkeys such asmsandrouteshould appear, alongsideseverityandservice. -
Run a query.
level = errorshould filter correctly, andservice = checkoutshould scope to one service.
Next
- AWS equivalent: Ingest from AWS CloudWatch
- OTLP endpoint details: OpenTelemetry ingest
- How patterns use message, level, and service: Patterns and findings
- Method chooser: Ingest your logs
- Return to Workflows