Measuring Cold Starts with CloudWatch and Cloud Trace
Cold start latency is not a metric on any of the three clouds by default — it is a field in a log line on AWS, a distribution on a container metric on GCP, and a boolean column in a KQL table on Azure. Extract it once and it becomes a percentile you can alert on: a CloudWatch metric filter on Init Duration from the REPORT line, an EMF record carrying your own gdal_init_ms, a run.googleapis.com/container/startup_latencies query on Cloud Run, and requests | where customDimensions.ColdStart == "True" in Application Insights. Without that extraction the Cold Start Comparison numbers stay anecdotes, and you cannot tell whether a layer change helped.
The Problem With Anecdotal Cold Starts
Every team that runs a Python GDAL function has a story about a 12-second first request. Almost none of them can say what the p99 initialisation time was last Tuesday, or whether trimming 40 MB off the GDAL layer moved it. The reason is structural: the platforms report initialisation cost through a channel that is not the metrics channel, and nobody wires the two together.
On AWS Lambda the REPORT line at the end of every invocation carries Init Duration: 4231.45 ms — but only on a cold invocation, and only in the log stream. It is not a CloudWatch metric, so it cannot be graphed, percentiled or alarmed on until you extract it. On GCP, Cloud Run publishes container/startup_latencies as a distribution, which is closer to useful but attributes the whole container start to one number with no idea which part was import rasterio. On Azure, Application Insights records a ColdStart custom dimension on the request telemetry, which is a flag rather than a duration.
This matters more for geospatial functions than for most workloads because the initialisation cost is both large and controllable. The breakdown in Cold Start Mapping for Python GDAL shows shared-library resolution and module import dominating a 6-second cold start, and both respond to packaging changes. If you cannot measure the split, the packaging work in Python Layer Management and Size Reduction is uncosted guesswork.
Prerequisites
- Runtime: Python 3.11 or 3.12 with GDAL 3.6+ available through a layer, a container image, or the GCP buildpack.
- AWS: the execution role needs
logs:CreateLogGroup,logs:CreateLogStreamandlogs:PutLogEvents; the deploying principal needslogs:PutMetricFilterandcloudwatch:PutMetricAlarm. SetAWS_LAMBDA_LOG_FORMAT=JSONif you want structured logs, but note the caveat below — it changes theREPORTline’s shape. - GCP: enable
cloudtrace.googleapis.comandmonitoring.googleapis.com. The runtime service account needsroles/cloudtrace.agentandroles/monitoring.metricWriter. Installopentelemetry-sdk1.24+ andopentelemetry-exporter-gcp-trace1.6+. - Azure: an Application Insights resource with
APPLICATIONINSIGHTS_CONNECTION_STRINGset in the function app configuration, andazure-monitor-opentelemetry1.2+ inrequirements.txt. - Environment variables that must be explicit, because they change what initialisation costs:
GDAL_DATA=/opt/share/gdal,PROJ_LIB=/opt/share/proj,LD_LIBRARY_PATH=/opt/lib— if any of these is unset, GDAL falls back to a filesystem search that adds seconds to init and makes your measurements meaningless.GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR— otherwise the firstOpen()inside the handler issues a list call whose latency shows up in your handler timing rather than in init.POWERTOOLS_METRICS_NAMESPACE=GeoPipelineandPOWERTOOLS_SERVICE_NAME=cog-readerif you use the AWS Lambda Powertools EMF helper.
- A comparable baseline. Measure the same layer, the same memory tier and the same input on every platform, or you are comparing packaging decisions rather than platforms.
The Signals, Platform by Platform
Each cloud puts the number somewhere different, and each requires a different extraction. The AWS route is a metric filter over the log group, which converts a log field into a real metric with no code change. The GCP route is a built-in distribution metric plus an explicit trace span for the part you control. The Azure route is a KQL query over the requests table, joined against customDimensions.
AWS: a metric filter over the REPORT line
# Convert the Init Duration field of every REPORT line into a CloudWatch metric.
# The filter pattern binds the fields positionally; $initDuration is the value.
aws logs put-metric-filter \
--log-group-name "/aws/lambda/cog-reader" \
--filter-name "InitDuration" \
--filter-pattern '[report="REPORT", requestIdLabel, requestId, durationLabel, duration, durationUnit, billedLabel, billedDurationLabel, billedDuration, billedUnit, memoryLabel, memorySizeLabel, memorySize, memoryUnit, maxMemoryLabel, maxMemoryUsedLabel, maxMemoryUsed, maxMemoryUnit, initLabel, initDurationLabel, initDuration, initUnit]' \
--metric-transformations \
metricName=InitDurationMs,metricNamespace=GeoPipeline,metricValue='$initDuration',unit=Milliseconds
# Alarm on the p99, not the average — cold starts are a tail phenomenon.
aws cloudwatch put-metric-alarm \
--alarm-name "cog-reader-cold-start-p99" \
--namespace GeoPipeline --metric-name InitDurationMs \
--extended-statistic p99 --period 300 --evaluation-periods 3 \
--threshold 8000 --comparison-operator GreaterThanThreshold
Because the pattern only matches REPORT lines that have the init fields, warm invocations are silently excluded — the metric is a clean population of cold starts only. Counting the difference between total invocations and metric sample count gives you the cold-start rate for free.
AWS: an EMF record that splits GDAL out of the total
The metric filter gives you the platform’s number. It cannot tell you how much of it was import rasterio versus the runtime bootstrap. For that, time the phases yourself at module scope and emit an Embedded Metric Format record — a JSON log line CloudWatch parses into metrics with no filter at all.
"""Module-scope instrumentation: time each init phase, emit one EMF record."""
import json
import os
import time
# Wall clock at import. Everything before this belongs to the runtime bootstrap.
_MODULE_T0 = time.perf_counter()
_phases = {}
def _phase(name):
"""Context manager that records the duration of one initialisation phase."""
class _P:
def __enter__(self):
self.t = time.perf_counter()
return self
def __exit__(self, *exc):
_phases[name] = round((time.perf_counter() - self.t) * 1000, 2)
return False
return _P()
with _phase("import_rasterio_ms"):
import rasterio # noqa: E402
from rasterio.session import AWSSession # noqa: E402
with _phase("import_pyproj_ms"):
import pyproj # noqa: E402
with _phase("gdal_env_ms"):
# Validate the paths that, when wrong, silently add seconds to every init.
for var in ("GDAL_DATA", "PROJ_LIB", "LD_LIBRARY_PATH"):
if not os.environ.get(var):
raise RuntimeError(f"{var} is unset — init timings will not be comparable")
_GDAL_ENV = dict(
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
GDAL_CACHEMAX="512",
CPL_VSIL_CURL_CACHE_SIZE="67108864",
)
_SESSION = AWSSession()
with _phase("proj_first_transform_ms"):
# The first transform pays for loading proj.db. Doing it here moves that
# cost out of the first request and into a phase you can see.
pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform(0, 0)
_phases["module_total_ms"] = round((time.perf_counter() - _MODULE_T0) * 1000, 2)
# One EMF record per container, emitted at import. CloudWatch turns the fields
# named in Metrics[] into real metrics; the rest ride along as context.
print(json.dumps({
"_aws": {
"Timestamp": int(time.time() * 1000),
"CloudWatchMetrics": [{
"Namespace": "GeoPipeline",
"Dimensions": [["service", "gdal_version"]],
"Metrics": [{"Name": n, "Unit": "Milliseconds"} for n in _phases],
}],
},
"service": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "cog-reader"),
"gdal_version": rasterio.__gdal_version__,
**_phases,
}))
_IS_COLD = True
def handler(event, context):
global _IS_COLD
cold = _IS_COLD
_IS_COLD = False # every later invocation on this container is warm
with rasterio.Env(session=_SESSION, **_GDAL_ENV):
with rasterio.open(event["uri"].replace("s3://", "/vsis3/", 1)) as src:
profile = {"width": src.width, "height": src.height,
"blocks": list(src.block_shapes[0])}
return {"statusCode": 200,
"body": json.dumps({"cold": cold, "init_phases": _phases,
"profile": profile})}
The EMF record is emitted once per container, at import time. On a warm container it never fires again, so the metric population is exactly the set of cold starts, and each sample carries the four-way split. gdal_version as a dimension means a layer upgrade shows up as a new series rather than a step change in an existing one.
GCP: the built-in distribution and an explicit span
# Startup latency distribution for a Cloud Run service, aligned to 5 minutes.
gcloud monitoring time-series list \
--project "$PROJECT" \
--filter='metric.type="run.googleapis.com/container/startup_latencies"
AND resource.labels.service_name="cog-reader"' \
--format='table(points[0].value.distributionValue.mean,
points[0].value.distributionValue.count)'
For the geospatial share of that number, open a span around the same module-scope block and export it to Cloud Trace:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(CloudTraceSpanExporter())
)
tracer = trace.get_tracer("cog-reader")
with tracer.start_as_current_span("gdal.init") as span:
import rasterio
span.set_attribute("gdal.version", rasterio.__gdal_version__)
span.set_attribute("gdal.cachemax_mb", 512)
span.set_attribute("proj.network", "OFF")
The span nests inside the request trace of the first request on that instance, so Cloud Trace’s waterfall shows container start, then gdal.init, then the handler — which is the picture that tells you whether min-instances is worth paying for, as covered in Reducing Cloud Functions 2nd Gen Cold Starts with Min Instances.
Azure: KQL over Application Insights
// Cold-start rate and cold-only latency for one function over 24 hours.
// duration on the requests table is milliseconds.
requests
| where timestamp > ago(24h)
| where cloud_RoleName == "cog-reader"
| extend cold = tobool(customDimensions["ColdStart"])
| summarize
invocations = count(),
cold_starts = countif(cold),
p50_all_ms = percentile(duration, 50),
p95_cold_ms = percentile(iff(cold, duration, real(null)), 95),
p99_cold_ms = percentile(iff(cold, duration, real(null)), 99)
by bin(timestamp, 1h)
| extend cold_rate_pct = round(100.0 * cold_starts / invocations, 2)
| order by timestamp asc
percentile ignores nulls, so projecting warm rows to real(null) inside iff() gives a cold-only percentile in the same summarize as the overall rate. The ColdStart dimension is populated by the Azure Functions host, not by your code — if it is absent, the worker is on an older runtime and you need azure-monitor-opentelemetry to emit it yourself.
Verification
Force a cold start and read back what each pipeline captured. On AWS, updating an environment variable is enough to retire every warm container without redeploying code:
aws lambda update-function-configuration \
--function-name cog-reader \
--environment "Variables={GDAL_DATA=/opt/share/gdal,PROJ_LIB=/opt/share/proj,LD_LIBRARY_PATH=/opt/lib,GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR,COLD_NONCE=$(date +%s)}" >/dev/null
aws lambda invoke --function-name cog-reader \
--payload '{"uri":"s3://eo-scenes/S2B_33UUP_20260714.tif"}' \
--cli-binary-format raw-in-base64-out /dev/stdout | jq '.body|fromjson|.init_phases'
Expected output — the four phases sum to roughly the platform’s own Init Duration, and the imports dominate:
{
"import_rasterio_ms": 2184.31,
"import_pyproj_ms": 612.77,
"gdal_env_ms": 3.94,
"proj_first_transform_ms": 431.06,
"module_total_ms": 3232.08
}
Cross-check against the platform’s own figure with a Logs Insights query over the same window:
filter @type = "REPORT"
| stats count() as invocations,
count(@initDuration) as cold_starts,
pct(@initDuration, 50) as p50_init_ms,
pct(@initDuration, 99) as p99_init_ms
If module_total_ms is 3,232 and p50_init_ms is 4,600, the ~1,370 ms difference is the runtime bootstrap and archive unpack — the part packaging work in Stripping Unnecessary Python Packages from AWS Lambda Layers attacks. If the two numbers are nearly equal, your imports are the whole problem and layer trimming will not help.
Gotchas
-
AWS_LAMBDA_LOG_FORMAT=JSONbreaks positional metric filter patterns. In JSON log mode theREPORTline becomes a structured record and the space-delimited[report="REPORT", ...]pattern matches nothing — silently, with the metric simply reporting no data. Switch the filter to{ $.record.metrics.initDurationMs = * }when you change log format. -
Provisioned concurrency removes
Init Durationfrom the REPORT line entirely. Initialisation happens outside any request, so there is no field to extract and your cold-start metric goes to zero samples. That is correct behaviour, but an alarm configured to fire on missing data will page you for a successful optimisation. Set the alarm’streat-missing-datatonotBreaching. -
The first
pyproj.Transformercosts more than the import. Loadingproj.dbis lazy, so a naive measurement attributes it to whichever request happens to transform first. Forcing one transform at module scope — as the code above does — moves it into the init window where it belongs and stops it polluting handler percentiles. -
Cloud Run’s
startup_latenciescovers the container, not your code. A trimmed image lowers it; a trimmed Python import does not, because imports run after the container is reported started. Always pair the metric with thegdal.initspan or you will conclude that import cost is free.
Frequently Asked Questions
Is Init Duration included in the billed duration on AWS Lambda?
Not for on-demand invocations — the REPORT line prints it separately and Billed Duration covers only the handler. On a provisioned-concurrency instance initialisation happens outside the request entirely, so no Init Duration field appears at all, which makes its absence a reliable warm-start detector.
Why does CloudWatch have no built-in cold start metric?
Init Duration is a field in a log line, not a published metric. To percentile or alarm on it you must extract it, either with a metric filter over the log group or by emitting your own EMF record from module scope. Both are shown above; the EMF route additionally splits GDAL’s share out of the total.
How do I see cold starts on Cloud Run?
Query run.googleapis.com/container/startup_latencies for the container-side cost, and wrap module-scope GDAL initialisation in an explicit OpenTelemetry span named gdal.init so the Python share appears as its own bar in the Cloud Trace waterfall.
What is a reasonable p99 cold start target for a Python GDAL function?
Under 6 seconds on a 2,048 MB function with a trimmed layer, and under 1 second on a warm-kept instance. If p99 sits above 10 seconds the usual causes are an untrimmed layer, unset GDAL_DATA/PROJ_LIB forcing a filesystem search, or a first transform pulling in a datum grid over the network.
Related
- Cold Start Comparison: AWS vs GCP vs Azure — the platform-by-platform numbers these queries produce
- Cold Start Mapping for Python GDAL — what each initialisation phase actually does
- Reducing Python GDAL Cold Starts with Provisioned Concurrency — the fix whose effect this instrumentation lets you verify
- Reducing Cloud Functions 2nd Gen Cold Starts with Min Instances — the GCP equivalent, read through Cloud Trace
- Stripping Unnecessary Python Packages from AWS Lambda Layers — the packaging work whose payoff shows up in
module_total_ms