Metrics and Telemetry#
Lithops already measures a great deal about every call it runs: how long the function took, how long it waited to be scheduled, how much memory and CPU the worker used, how much data it moved, whether the worker was cold, and how big the result was. All of it comes back with the call status, and until the job ends it lives only in the client process.
Telemetry turns those numbers into metrics, so a job can be watched while it runs and compared against every job that ran before it.
lithops:
telemetry: prometheus
How it works#
Metrics are produced entirely on the client. The worker writes its status
exactly as it always did; the monitor thread that reads the status feeds the
numbers into the exporter on its way past, and a background thread pushes what
has been aggregated every telemetry_interval seconds.
Nothing is added to the critical path of a function, no worker needs a route to
the metrics system, and every monitoring backend — storage, rabbitmq,
redis, aws_sqs, gcp_pubsub, azure_queue — is instrumented by the
same code.
Two backends ship with Lithops:
Backend |
Use it when |
|---|---|
|
There is a Prometheus Pushgateway, or you would rather not add the OpenTelemetry SDK to the client. Lithops pushes to the gateway, which Prometheus then scrapes. One small pure-Python dependency. |
|
Everything else, including Prometheus. Lithops exports over OTLP, which Prometheus can receive directly, an OpenTelemetry collector can forward to Prometheus over remote write, and a hosted service can take as it is. No Pushgateway, and none of the caveats that come with one. |
Both backends export the same metric names, so a dashboard written against one works against the other, and switching is a one-line configuration change.
Install the dependencies with:
pip install lithops[telemetry]
Enabling it#
telemetry names the backend. true is accepted and selects
prometheus; the default is false, and while telemetry is off the client
library of a backend is never even imported.
Group |
Key |
Default |
Additional info |
|---|---|---|---|
lithops |
telemetry |
|
Telemetry backend. One of |
lithops |
telemetry_interval |
|
Seconds between pushes. Metrics are also pushed once when the executor is cleaned up and once when the process exits. |
Prometheus#
Lithops is a client, not a server: it runs for as long as the job does, so there is nothing for Prometheus to scrape. The Pushgateway exists for exactly that. Lithops keeps a registry of cumulative metrics and replaces its Pushgateway group with it on every push.
Installing Prometheus and the Pushgateway#
The quickest way to get all three services up, including Grafana:
# docker-compose.yml
services:
pushgateway:
image: prom/pushgateway
ports: ["9091:9091"]
prometheus:
image: prom/prometheus
ports: ["9090:9090"]
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"]
grafana:
image: grafana/grafana
ports: ["3000:3000"]
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: pushgateway
honor_labels: true # keep the job/instance labels Lithops pushes
static_configs:
- targets: ["pushgateway:9091"]
honor_labels: true matters: without it Prometheus overwrites the job
and instance labels of everything the gateway serves with the identity of
the gateway itself.
Configuration#
lithops:
telemetry: prometheus
prometheus:
gateway: http://localhost:9091
Group |
Key |
Default |
Additional info |
|---|---|---|---|
prometheus |
gateway |
|
Pushgateway base URL, with the scheme and the port. |
prometheus |
job |
|
The |
prometheus |
instance |
the hostname |
The |
prometheus |
username |
Basic auth user, when the gateway is behind one. |
|
prometheus |
password |
Basic auth password, when the gateway is behind one. |
|
prometheus |
timeout |
|
Push timeout, in seconds. |
Note
A Pushgateway group never expires: it is served to Prometheus until
something deletes it. instance therefore defaults to the hostname
rather than to anything derived from the execution, so that a machine
reuses one group for every job it ever runs instead of leaving one behind
on every run. Set it explicitly only to something equally stable, and give
two Lithops clients running concurrently on the same host two different
values, or they will overwrite each other’s group.
OpenTelemetry#
lithops:
telemetry: otlp
otlp:
endpoint: http://localhost:4318
Straight into Prometheus, without a collector#
Prometheus can receive OTLP itself, which means the otlp backend reaches it
with no collector and no Pushgateway in between. Start Prometheus with
--web.enable-otlp-receiver and point Lithops at its OTLP path:
lithops:
telemetry: otlp
otlp:
endpoint: http://localhost:9090/api/v1/otlp
This is the option to prefer for a new deployment. A Pushgateway group never expires and has to be reasoned about; a push straight into Prometheus is written to the TSDB and that is the end of it. The metric names Lithops exports are already in Prometheus form, so they arrive unchanged.
Trying it end to end, locally#
Grafana publishes a single container that is an entire OpenTelemetry backend — collector, Prometheus, Loki, Tempo and Grafana — meant for exactly this. It is a development image, not a production one.
# docker-compose.yml
services:
lgtm:
image: grafana/otel-lgtm
ports:
- "4318:4318" # OTLP/HTTP, where Lithops pushes
- "3000:3000" # Grafana
pip install lithops[telemetry]
docker compose up -d
Point a localhost executor at it, so that nothing leaves the machine:
lithops:
backend: localhost
storage: localhost
telemetry: otlp
telemetry_interval: 5
otlp:
endpoint: http://localhost:4318
import lithops
def double(x):
return x * 2
with lithops.FunctionExecutor() as fexec:
fexec.map(double, range(20))
print(fexec.get_result())
Open Grafana on http://localhost:3000, pick the Prometheus datasource, and the
metrics are there within a telemetry_interval. lithops_calls_completed_total
is the one to check first; lithops_worker_info shows the runtime the calls
ran in.
Prometheus on its own#
Leaner, if the dashboards are not the point yet:
# docker-compose.yml
services:
prometheus:
image: prom/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --web.enable-otlp-receiver
ports: ["9090:9090"]
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"]
# prometheus.yml
storage:
tsdb:
# Pushed metrics do not arrive in the order Prometheus scrapes in
out_of_order_time_window: 30m
otlp:
promote_resource_attributes:
- service.instance.id
- service.name
lithops:
telemetry: otlp
otlp:
endpoint: http://localhost:9090/api/v1/otlp
Lithops sets service.name and service.instance.id, which Prometheus
turns into the job and instance labels — the same identity the metrics
would have had through the Pushgateway.
Through a collector#
The collector is worth the extra hop when Lithops is one workload among many, or when the same metrics have to reach more than one destination:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
service:
pipelines:
metrics:
receivers: [otlp]
exporters: [prometheusremotewrite]
Prometheus needs --web.enable-remote-write-receiver for that endpoint.
Group |
Key |
Default |
Additional info |
|---|---|---|---|
otlp |
endpoint |
|
Collector endpoint. For |
otlp |
protocol |
|
One of |
otlp |
service_name |
|
The |
otlp |
instance |
the hostname |
The |
otlp |
headers |
|
Extra headers, which is where the authentication of a hosted collector goes. |
otlp |
timeout |
|
Export timeout, in seconds. |
What is exported#
Counters are exported without the _total suffix; Prometheus and
OpenTelemetry both add it themselves.
Metric |
Type |
What it measures |
|---|---|---|
|
counter |
Jobs handed to the compute backend. |
|
counter |
Function calls handed to the compute backend. |
|
counter |
Calls the client has seen start running. |
|
counter |
Calls that reached a final state, by |
|
counter |
Calls that ran in a cold worker. |
|
counter |
CPU time consumed by the function, by |
|
counter |
Network traffic of the function, by |
|
histogram |
Wall clock time spent inside the user function. |
|
histogram |
Wall clock time the worker spent on the call, Lithops overhead included. |
|
histogram |
Submission to worker start. The scheduling latency of the backend. |
|
histogram |
Worker start to the user function starting: unpacking the job, importing the modules, fetching the data. |
|
histogram |
User function returning to the worker finishing: writing the result and reporting the status. |
|
histogram |
Worker finish to client notice. The latency of the monitoring channel. |
|
histogram |
Client notice to the client holding the result, by |
|
histogram |
Storage requests the client made to get one result. Zero for an inline result. |
|
histogram |
Submission to the result in hand. The number the user actually feels — every other timing above is a slice of this one. |
|
histogram |
Size of the value the function returned. |
|
histogram |
Time the worker spent writing the result to storage. Zero for a result small enough to travel in the call status. |
|
histogram |
Storage requests the client made to learn the status of one call — what the monitoring channel costs against storage. Zero for the channels that push. |
|
histogram |
Memory of the worker, by |
|
histogram |
Memory over the memory the runtime was configured with, by |
|
histogram |
CPU utilisation of the worker while the function ran. |
|
gauge |
Always 1. Carries |
|
histogram |
How many calls a job is made of. |
|
histogram |
Everything the client did before the first worker was invoked. |
|
histogram |
Of that, the time spent serialising the function and its data. |
|
histogram |
Of that, the time spent partitioning the input, for a job that processes objects. |
|
histogram |
Of that, the time spent uploading, by |
|
histogram |
Size of what the client uploaded, by |
Note
The five kinds of lithops_worker_memory_bytes answer different
questions, and it is worth knowing which one to look at.
peak is the high water mark of the whole task, taken from
getrusage, and it is the number an out-of-memory kill is measured
against — this is the one to size a runtime by. baseline is that same
mark taken before the function started, so it is what the runtime itself
costs before any user code runs, and peak - baseline is what the
function added. rss, vms and uss are single samples taken as
the function returns, useful for the shape of the memory but not for the
ceiling. peak and baseline are absent on workers that are not Unix.
Note
The timing metrics decompose one call end to end, so a stacked graph of
them accounts for every second of lithops_call_end_to_end_seconds:
host_submit ──┬── queue_delay ──── the backend scheduling the call
│
worker_start ─┼── worker_setup ─── unpack, import, fetch data
│
func_start ───┼── duration ─────── the user function
│
func_end ─────┼── worker_teardown ─ write the result, report
│
worker_end ───┼── status_latency ── the monitoring channel
│
status_done ──┼── result_download ─ fetching what it returned
│
result_done ──┘
lithops_call_total_duration_seconds is the worker’s share of that
(setup + duration + teardown), which is the part a FaaS backend bills for.
result_download is only recorded for the calls whose result is actually
read: a job whose results are never fetched has none.
Every metric carries the same four labels, plus the one its own dimension adds.
lithops_worker_info is the exception: it deliberately leaves out
function_name, because it describes what a runtime is rather than what
ran on it.
Label |
Value |
|---|---|
|
The compute backend the job ran on, e.g. |
|
The runtime the job ran in. |
|
The memory the runtime was configured with, in MB. |
|
The name of the function that was mapped. |
Note
There is deliberately no job_id, call_id or activation_id
label. Every distinct combination of label values is a separate time
series, and Lithops runs jobs of tens of thousands of calls: a per-call
label would mean a series per call, kept for ever. The label set is
therefore bounded by how many distinct functions and runtimes are executed,
not by how much work is done — which is what lets a 100,000 call job be
summarised by histogram_quantile in a single query.
Per-call detail is not lost. It is in the call status, which the client
keeps, and which fexec.plot() and fexec.job_summary() render.
Querying it#
Note
A Lithops client is a script, not a service. Its counters live for as
long as the process does and start again from zero in the next run, so
rate() over a five minute window is zero for most of the window and
meaningless for the rest. Read the counters as they stand instead —
sum(x) for a total, sum(x_sum) / sum(x_count) for a mean,
histogram_quantile(q, sum by (le) (x_bucket)) for a percentile.
rate() comes into its own only for a long-lived client.
# Calls completed, by outcome
sum by (outcome) (lithops_calls_completed_total)
# Calls still in flight
sum(lithops_calls_invoked_total) - sum(lithops_calls_completed_total)
# Failure rate
sum(lithops_calls_completed_total{outcome!="success"})
/ sum(lithops_calls_completed_total)
# Cold start rate
sum(lithops_cold_starts_total) / sum(lithops_calls_completed_total)
# p95 execution time
histogram_quantile(0.95,
sum by (le, function_name) (lithops_call_duration_seconds_bucket))
# End-to-end latency as the caller feels it, p95
histogram_quantile(0.95,
sum by (le, function_name) (lithops_call_end_to_end_seconds_bucket))
# Scheduling latency of the backend, p95
histogram_quantile(0.95,
sum by (le, backend) (lithops_call_queue_delay_seconds_bucket))
# Runtimes that are over-provisioned: p95 peak memory well under 1.
# Use kind="peak" — summing across the kinds mixes two distributions
histogram_quantile(0.95,
sum by (le, runtime_name, runtime_memory)
(lithops_worker_memory_utilization_ratio_bucket{kind="peak"}))
# What the runtime costs before any user code runs
sum by (runtime_name) (lithops_worker_memory_bytes_sum{kind="baseline"})
/ sum by (runtime_name) (lithops_worker_memory_bytes_count{kind="baseline"})
# Mean time per call in one phase, e.g. what the client spent preparing
sum(lithops_job_create_seconds_sum) / sum(lithops_job_create_seconds_count)
# What the monitoring channel costs against the storage backend,
# in requests per call. Zero means a channel that pushes
sum(lithops_call_status_queries_sum) / sum(lithops_call_status_queries_count)
# How often a result was small enough to skip storage entirely
sum(lithops_call_result_download_seconds_count{source="inline"})
/ sum(lithops_call_result_download_seconds_count)
# Worker seconds, the billed part of a call, split by the memory the
# runtime was given. PromQL cannot turn the runtime_memory label into
# a number, so multiply the two columns in Grafana for GB-seconds
sum by (function_name, runtime_memory) (
lithops_call_total_duration_seconds_sum)
# The execution environments in use, for an "environment" panel
count by (backend, runtime_name, runtime_memory, python_version,
lithops_version) (lithops_worker_info)
A ready-made Grafana dashboard#
docs/source/grafana/lithops-dashboard.json is the dashboard these
queries add up to: an overview row, throughput and latency, the phase
decomposition of a call, memory, storage, client-side job preparation, and
a table of the execution environments. It has backend, runtime and
function template variables, and asks for the Prometheus datasource on
import.
In Grafana: Dashboards → New → Import → Upload JSON file, then pick the Prometheus datasource. Or over the API:
curl -u admin:admin -H 'Content-Type: application/json' \
-X POST http://localhost:3000/api/dashboards/db \
-d "{\"dashboard\": $(cat docs/source/grafana/lithops-dashboard.json), \
\"overwrite\": true}"
Two panels stay empty on the localhost backend, and correctly so: memory
utilisation needs a runtime_memory to divide by, and the partitioning
time only exists for a job that processes objects.
Troubleshooting#
Nothing arrives. Metrics are pushed every telemetry_interval seconds,
so a job that finishes in less than that is only visible after the push that
the executor makes on cleanup. Raise the log level to DEBUG: the exporter
logs the backend it started with and every push it could not make.
Metrics arrive but Prometheus shows the gateway’s own labels. Set
honor_labels: true on the scrape config.
Telemetry never breaks a job. A metrics system that is misconfigured or
unreachable is logged and switched off, not raised. If telemetry seems to be
doing nothing at all, DEBUG will say why.