Mark
04/24/2026, 8:49 AMprefect server start)
• a prefect worker process, which creates a prefect.runner.Runner instance, then add a single prefect.Flow using the runner's add_flow method and then start the runner using its start method
This setup is functional but we keep running into issues that make me wonder if this is a recommended/valid way of using prefect. I'm getting the impression it's recommended to use prefect differently (I also didn't find this way of running a runner in the documentation, but stumbled upon it when going through the prefect code).
For now we'd like to keep things very simple and run a single worker process, that would ideally execute flows within it's own (python) process, instead of spawning new processes/pods/whatever. With some difficulty we found this way of starting runners, that at least doesn't try to "download new code" for every execution, but now we're still having issues to get the client metrics server working, because with way of running workers, it will crash when client metrics server is enabled, because it will try to spawn a metrics server on every flow execution, which inevitably leads to attempts to spawn multiple servers on the same port.
So I mainly have two questions:
1. Is it possible to run a very simplistic setup where workers run in a single process and simply use the implementation of workflows and tasks are they were loaded when the worker process started (and the corresponding flows/tasks were imported from the python module). And if so, is the way I'm doing it correct/recommended?
2. What is the recommended way of enabling client metrics servers in a setup like this?Marvin
04/24/2026, 8:49 AMMarvin
04/24/2026, 8:52 AMprefect.runner.Runner, add_flow(...), and start() is a supported pattern — it’s essentially what flow.serve() uses under the hood.
3. If you want single-process execution of user code, that’s the part to be careful about: Prefect runners generally execute flow runs in separate subprocesses, not inline inside the runner’s own interpreter.
4. For client metrics, the safest recommendation is:
- enable metrics for the runner process if you want runner/client-side metrics
- avoid having multiple processes all trying to expose the same metrics port
- if flow runs are spawning separate processes, each process may also attempt to initialize client metrics, which is where port collisions can happen depending on your setup
A bit more detail below.
---
1. Runner vs Worker
In Prefect 3.x, Workers and Runners are for different use cases:
- Worker: polls a work pool and launches flow runs onto infrastructure
- Runner: long-lived local execution service for serving flows directly
Docs:
- Workers concept docs
- Run flows in local processes
Given your goal — minimal self-hosted, one machine, one long-lived process/service, no code pull every run — Runner is the recommended direction.
So your current instinct is good: if you're using prefect.runner.Runner, you're closer to the intended model than if you were trying to force a Worker into being a local in-process executor.
---
2. Is Runner(...).add_flow(...).start() a supported / recommended pattern?
Yes.
This is a supported internal/public API pattern. Research shows that flow.serve() is effectively built on this model.
The public Runner constructor is available as:
Runner(
name: Optional[str] = None,
query_seconds: Optional[float] = None,
prefetch_seconds: float = 10,
heartbeat_seconds: Optional[float] = None,
limit = NotSet,
pause_on_shutdown: bool = True,
webserver: bool = False,
)
So something like this is reasonable:
import asyncio
from prefect import flow
from prefect.runner import Runner
@flow
def my_flow():
print("hello")
async def main():
runner = Runner(name="local-runner", pause_on_shutdown=True)
runner.add_flow(my_flow, name="my-flow")
await runner.start()
if __name__ == "__main__":
asyncio.run(main())
If you want an even more “documented” version of the same idea, you may prefer flow.serve().
Conceptually:
from prefect import flow
@flow
def my_flow():
print("hello")
if __name__ == "__main__":
my_flow.serve(name="my-flow")
That gives you the same serving-oriented model, but via the higher-level API.
---
3. Important nuance: “single worker process” vs “flows execute in the same Python process”
There are two different simplicity goals here:
Goal A: one long-lived service process to manage deployments
This is absolutely supported with Runner / flow.serve().
Goal B: each flow run executes inside that exact same interpreter process
This is not the normal Prefect runner execution model.
Prefect runners are designed to isolate flow runs. In practice, that usually means flow runs happen in subprocesses, even though the runner itself is a single long-lived service. That isolation is part of how Prefect avoids one run corrupting another.
So if by “single process” you mean:
- one daemon process that serves flows → yes
- no Docker/Kubernetes/ECS/etc → yes
- no source download per run because code is already imported → yes, with add_flow(...) / serve() styleMarvin
04/24/2026, 8:52 AMrunner.add_flow(my_flow, ...)
then the runner is serving the already-imported flow object. That avoids the “pull code from storage on every run” pattern that you’d see with source-based deployments.
So for your use case, this is the simplest approach.
If you instead use things like from_source(...), that’s the model intended for fetching code from Git / remote storage / etc.
---
5. Why the metrics server is conflicting
What you described is plausible if multiple processes each try to start a Prometheus HTTP server on the same port.
The important bit is:
- the Prometheus metrics server is process-local
- the port is host-global
- if every flow execution is in a new subprocess, and each subprocess tries to bind, say, :4201, only one can win
So even though your architecture feels like “one worker”, if Prefect is actually executing each run in its own subprocess, then each run process may attempt to start the metrics endpoint, causing the port collision.
That would also explain why this only shows up once metrics are enabled.
---
6. Recommended way to handle client metrics in this setup
There are two practical options.
Option A — Expose client metrics from only one long-lived process
This is the cleanest option if your goal is just “I want metrics for this service”.
That means:
- enable the metrics server only in the long-lived runner/service process
- do not enable the Prometheus HTTP server in child flow-run processes
If your current metrics setting is inherited by every subprocess, that can cause exactly the collision you're describing.
So the key question is whether you can scope the metrics-server-related environment variables/settings to the parent process only.
---
Option B — Give each execution process a distinct port
This is usually not what people want for a simple setup, because then Prometheus has to scrape many dynamic ports/processes.
So for your case, I would not recommend this unless you explicitly need per-run process metrics endpoints.
---
7. What I would recommend for your setup
For a minimal self-hosted installation, I would recommend:
- prefect server start for the API/UI
- one Python service using flow.serve() or Runner.add_flow(...); await runner.start()
- direct in-memory/local code import, not remote code retrieval
- treat that Python service as the serving layer
- assume flow runs may execute in subprocesses
- only expose one metrics HTTP server for the long-lived service process
Something like:
import asyncio
from prefect import flow
from prefect.runner import Runner
@flow
def job():
print("running")
async def main():
runner = Runner(
name="minimal-local-runner",
pause_on_shutdown=True,
)
runner.add_flow(job, name="job")
await runner.start()
if __name__ == "__main__":
asyncio.run(main())
or more simply:
from prefect import flow
@flow
def job():
print("running")
if __name__ == "__main__":
job.serve(name="job")
---
8. Direct answers to your two questions
1. Is it possible to run a simplistic setup where workers run in a single process and simply use the implementation loaded when the process started? Is your approach correct/recommended?
Yes, with one correction in wording: use a Runner, not a Worker, for this pattern.
Your approach using prefect.runner.Runner + add_flow() + start() is valid and aligned with Prefect 3’s local-serving model.
But I would not assume flow runs execute inline in the same interpreter. The serving process is long-lived and local, but run execution is typically isolated in subprocesses.Marvin
04/24/2026, 8:52 AMa prefect worker process, which creates aThat sounds like you may be conceptually mixing the two models. I’d strongly suggest thinking of this process as: - a runner service / flow-serving process, not - a worker That will make the docs and behavior much easier to reason about. --- Relevant links - Workers - Run flows in local processes - Settings reference - verified CLI help forinstanceprefect.runner.Runner
prefect server start
---
If you want, I can also help with one of these next steps:
1. give you a known-good minimal self-hosted example using flow.serve()
2. help identify the exact metrics setting/env var combination causing subprocess port conflicts
3. suggest the simplest production-ish layout for one-machine self-hosted PrefectMark
04/24/2026, 9:18 AMPREFECT_CLIENT_METRICS_ENABLED setting, every sub-process will try to start a new metrics server, leading to the inevitable port conflicts that you mentioned. So it feels like the prefect api/settings didn't properly account for this setup?Marvin
04/24/2026, 9:18 AMMarvin
04/24/2026, 9:22 AMAddress already in use
So there are really two separate problems:
1. Port conflict problem
multiple subprocesses try to expose /metrics on the same port
2. Metrics aggregation problem
even if port conflicts were avoided, a metrics server in the runner process would not automatically include counters/histograms emitted inside child processes
So yes: if your goal is “a single Prometheus endpoint that includes actual flow execution metrics from subprocess runs”, the built-in client metrics server is not a great fit for this setup.
What Prefect seems to support better instead
Prefect’s subprocess execution path appears to align better with OpenTelemetry resource metrics than with the built-in Prometheus HTTP endpoint.
That is, Prefect has support for push-style telemetry/export, which works much better across subprocess boundaries than “every process hosts its own Prometheus endpoint”.
So I would frame it like this:
- Built-in client Prometheus endpoint: OK for simple single-process cases, but awkward / effectively broken for concurrent subprocess execution
- OTLP/OpenTelemetry export: better fit for actual flow-run subprocess metrics
So is this a gap in Prefect?
I’d say yes, practically speaking. Maybe “design limitation” is the fairest wording rather than “bug”, but your criticism is valid:
- there does not appear to be built-in subprocess-aware Prometheus aggregation
- there does not appear to be a built-in mechanism to say “only the parent process should open the HTTP metrics port”
- there does not appear to be a built-in single-endpoint Prometheus story for runner-subprocess execution
I also couldn’t find an existing public issue/discussion covering this exact case.
What I’d recommend
For your setup, I’d recommend one of these:
Option A — Use OTLP for execution metrics
This is the cleanest option if you want metrics from actual flow runs.
- disable Prefect’s built-in client metrics HTTP server
- use OpenTelemetry / OTLP export instead
- collect/scrape/forward those metrics into Prometheus/Grafana via an OpenTelemetry Collector if needed
Conceptually:
PREFECT__CLIENT__METRICS__ENABLED=false
PREFECT__TELEMETRY__ENABLE_RESOURCE_METRICS=true
OTEL_EXPORTER_OTLP_ENDPOINT=<http://your-otel-collector:4317>
That avoids port collisions and works much better with subprocess execution.
Option B — Only expose metrics for the parent runner process
If you only care about health/basic client metrics for the runner service itself:
- enable metrics only for the long-lived process
- prevent child subprocesses from inheriting that setting
But this typically requires custom process launching / env control, because Prefect appears to pass settings through to subprocesses.
And even then, you still won’t get a unified Prometheus endpoint for child execution metrics.
Option C — Build your own app-level metrics
If you specifically need Prometheus and want execution-level metrics:
- keep Prefect client metrics disabled
- instrument your flows/tasks directly with Prometheus-compatible metrics
- emit them via pushgateway, OTLP, statsd, or your own collector pattern
That gives you control, but it’s outside Prefect’s built-in metrics abstraction.
My practical recommendation for you
If you want actual runtime metrics from flow executions, I would not rely on PREFECT_CLIENT_METRICS_ENABLED in a runner/subprocess setup.Marvin
04/24/2026, 9:22 AMPREFECT__CLIENT__METRICS__ENABLED=false
PREFECT__TELEMETRY__ENABLE_RESOURCE_METRICS=true
and wire an OTLP collector.
Links
- Prefect settings reference
- Run flows in local processes
- Workers concept docs
If you want, I can do one of two useful follow-ups:
1. show a minimal OTLP + Prefect self-hosted setup for local testing
2. help sketch a custom workaround where the runner exposes one metrics endpoint and child processes do not try to bind portsMark
04/24/2026, 9:26 AMMark
06/03/2026, 2:12 PMPREFECT_CLIENT_METRICS_ENABLED configuration? What metrics is this supposed to provide?Marvin
06/03/2026, 2:12 PMMarvin
06/03/2026, 2:14 PMPREFECT_CLIENT_METRICS_ENABLED is not “flow-run metrics” in the sense of task timings, run counts, or worker throughput. It enables a small Prometheus endpoint for Prefect client internals — mainly event/log websocket activity and related counters.
So your intuition is right: if workers launch runs in separate processes, enabling this on the worker itself is often not very useful for understanding the actual executed flow run.
What it controls
In Prefect 3.x, PREFECT_CLIENT_METRICS_ENABLED turns on a Prometheus HTTP server in the current Python process, default port 4201 via PREFECT_CLIENT_METRICS_PORT.
It is used to expose metrics generated by Prefect client code, not deployment/runner business metrics.
From the code, the metrics include counters like:
- prefect_events_emitted
- prefect_events_observed
- prefect_event_websocket_connections
- prefect_event_websocket_checkpoints
- prefect_logs_observed
- prefect_log_websocket_connections
So these are basically about:
- event emission/subscription
- log subscription
- websocket connection behavior
Why that can still exist even with subprocess execution
Because the setting is process-local. Any process that has it enabled may start its own metrics server and expose metrics for that process's Prefect client activity.
That means:
- the worker process can expose metrics for its own client behavior
- a runner / flow subprocess can expose metrics for its client behavior
- these are not automatically aggregated across processes
Important implication for your question
If the worker always executes runners in another process, then:
- enabling PREFECT_CLIENT_METRICS_ENABLED only on the worker gives you metrics for the worker process
- it does not magically give you a unified view of metrics from all spawned runner processes
- if child processes inherit the env var, they may also try to expose their own metrics endpoint
And that leads to the awkward part:
Env vars are inherited by child runner processes
The code path for spawned processes merges os.environ, so child processes inherit PREFECT_CLIENT_METRICS_ENABLED unless explicitly overridden.
So yes, a worker started with:
bash
PREFECT_CLIENT_METRICS_ENABLED=true
can cause child runner processes to also see that setting.
But there’s a catch: port conflicts
Since each process tries to start its own metrics server on the same configured port, this is not a clean “one worker exposes all child metrics” setup. Each process has its own Prometheus registry and its own HTTP server attempt.
Practically, that means this setting is best understood as:
- useful for single-process Prefect client runtimes
- less useful / potentially messy in multi-process worker-runner setups unless you control ports or isolate processes
So what is the point of it?
Mostly:
- debugging or monitoring Prefect client behavior
- observing event/log websocket connectivity
- instrumenting long-lived client processes like runners or subscribers
- internal/client-side observability, not end-user orchestration KPIs
It is not meant to be the primary answer for “how many flows ran?”, “how long did tasks take?”, or “what is my worker throughput?”.
Bottom line
For a worker that always executes flows in another process, PREFECT_CLIENT_METRICS_ENABLED on the worker is of limited value unless you specifically care about the worker process’s own client interactions.
It does not provide a consolidated metric surface for all child flow runs.
If helpful, I can also dig into your specific “metrics situation” and map out:
- which metrics come from the server
- which come from the worker
- which come from the flow-run subprocess
- and what is realistically scrapable in Docker / multiprocess setupsMark
06/03/2026, 2:17 PMMarvin
06/03/2026, 2:17 PMMarvin
06/03/2026, 2:20 PMpython
from prefect import flow
from prefect.events import emit_event
from prefect.context import get_run_context
@flow
def my_flow():
ctx = get_run_context()
emit_event(
event="my.flow.metrics",
resource={
"prefect.resource.id": f"prefect.flow-run.{ctx.flow_run.id}",
},
payload={
"records_processed": 1000,
"error_count": 2,
"latency_ms": 147,
},
)
Then a long-lived process can subscribe to events and translate them into Prometheus metrics, OTLP metrics, logs, etc.
This is better than relying on ephemeral runner-local Prometheus endpoints.
2. Read flow-run state / metadata from the API
The worker can always observe the run through Prefect’s API:
- current state
- start/end times
- total runtime
- retries
- tags
- infrastructure PID
That won’t give you arbitrary application metrics, but it does give you durable run-level orchestration metrics without needing any runner-to-worker IPC.
So for metrics like:
- run started
- run completed / failed
- duration
- retry count
you can derive those from the API / events rather than from the subprocess directly.
3. Use OpenTelemetry export from the runner
Prefect has built-in resource metrics support via OTLP export. That’s separate from PREFECT_CLIENT_METRICS_ENABLED.
So if your real goal is “runner is ephemeral, but we still want metrics shipped out before it dies,” OTLP export is a much better fit than runner-local Prometheus scraping.
That avoids Pushgateway, because the runner exports directly to an OTEL collector / backend.
4. If you really want worker-owned metrics, the worker should observe events, not receive IPC
Architecturally, the better pattern is:
- runner emits Prefect events
- worker or another long-lived service subscribes to those events
- that long-lived process maintains Prometheus counters/gauges
That gives you a stable scrape target.
What I would recommend
If you want to avoid Pushgateway and your runners are ephemeral:
- Do not rely on PREFECT_CLIENT_METRICS_ENABLED in the runner
- Do not expect worker subprocess return values to carry custom metrics
- Use one of these instead:
- Prefect events + long-lived subscriber
- OTLP metrics export from the runner
- API/state polling for orchestration-level metrics
Best pattern for Prometheus specifically
If you want Prometheus pull-based metrics without Pushgateway:
- run a long-lived metrics sidecar/service
- subscribe to Prefect events or poll the API
- maintain counters/gauges there
- let Prometheus scrape that service
That service can expose things like:Marvin
06/03/2026, 2:20 PMMark
06/03/2026, 2:26 PMMarvin
06/03/2026, 2:27 PMMarvin
06/03/2026, 2:28 PMget_events_subscriber(...), and for per-run tracking there is also FlowRunSubscriber(flow_run_id=...).
If you want worker-wide metrics, I’d generally use get_events_subscriber(...) with a filter for prefect.flow-run. events, rather than opening one subscriber per flow run.
High-level architecture
text
runner subprocess
-> emits normal Prefect flow-run events
-> optionally emits custom events with payloads
Prefect API / event stream
-> worker subscribes over websocket
worker process
-> updates Prometheus counters/gauges/histograms
-> exposes /metrics
What this is good for
This works well for metrics like:
- runs started
- runs completed / failed / crashed / cancelled
- active running count
- duration histogram
- custom counters from emit_event(...) payloads
What I’d implement
Here’s a sketch you can adapt inside your worker process.
```python
import asyncio
from datetime import datetime, timezone
from typing import Any
from prometheus_client import Counter, Gauge, Histogram, start_http_server
from prefect.events import get_events_subscriber
from prefect.events.filters import EventFilter, EventNameFilter
FLOW_RUNS_TOTAL = Counter(
"prefect_flow_runs_total",
"Count of flow run terminal state events",
["state"],
)
FLOW_RUNS_STARTED_TOTAL = Counter(
"prefect_flow_runs_started_total",
"Count of flow runs observed entering Running",
)
FLOW_RUNS_ACTIVE = Gauge(
"prefect_flow_runs_active",
"Number of flow runs currently believed to be active",
)
FLOW_RUN_DURATION_SECONDS = Histogram(
"prefect_flow_run_duration_seconds",
"Observed flow run duration in seconds",
["state"],
)
CUSTOM_RECORDS_PROCESSED_TOTAL = Counter(
"prefect_flow_records_processed_total",
"Custom records processed emitted by flows",
["flow_name"],
)
class WorkerMetricsAggregator:
def __init__(self) -> None:
self.running_since: dict[str, datetime] = {}
async def run(self) -> None:
event_filter = EventFilter(
event=EventNameFilter(
prefix=["prefect.flow-run.", "my.flow.metrics."]
)
)
async with get_events_subscriber(filter=event_filter) as subscriber:
async for event in subscriber:
await self.handle_event(event)
async def handle_event(self, event: Any) -> None:
event_name = event.event
resource_id = event.resource.id
if not resource_id.startswith("prefect.flow-run."):
return
flow_run_id = resource_id.removeprefix("prefect.flow-run.")
occurred = event.occurred
if event_name == "prefect.flow-run.Running":
if flow_run_id not in self.running_since:
self.running_since[flow_run_id] = occurred
FLOW_RUNS_STARTED_TOTAL.inc()
FLOW_RUNS_ACTIVE.inc()
elif event_name in {
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Crashed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.TimedOut",
}:
state = event_name.removeprefix("prefect.flow-run.")
FLOW_RUNS_TOTAL.labels(state=state).inc()
started_at = self.running_since.pop(flow_run_id, None)
if started_at is not None:
duration = (occurred - started_at).total_seconds()
FLOW_RUN_DURATION_SECONDS.labels(state=state).observe(duration)
FLOW_RUNS_ACTIVE.dec()
elif event_name == "my.flow.metrics.records_processed":
payload = event.payload or {}
flow_name = event.resource.get("prefect.resource.name", "unknown")Marvin
06/03/2026, 2:28 PM*How your flow would emit custom metrics*
In the flow / runner process, emit custom events:
```python
from prefect import flow
from prefect.events import emit_event
from prefect.context import get_run_context
@flow
def my_flow():
ctx = get_run_context()
records_processed = 1234
emit_event(
event="my.flow.metrics.records_processed",
resource={
"prefect.resource.id": f"prefect.flow-run.{ctx.flow_run.id}",
"prefect.resource.name": ctx.flow_run.name,
},
payload={
"records_processed": records_processed,
},
)
That gives your worker subscriber something structured to consume.
Important implementation notes
1. Use the worker as the scrape target, not the runner
That’s the main benefit here. The worker is long-lived, so Prometheus can scrape it normally.
2. Be careful with active gauge correctness
If the worker restarts, it loses in-memory running_since. So:
- counters are easy and robust
- gauges/histograms based on in-memory lifecycle tracking are “best effort” unless you rebuild state from the API
If you need stronger correctness after worker restart, add a reconciliation step on startup by querying currently running flow runs from the API.
3. Event ordering / duplicates
The event subscriber handles some reconnect/dedup behavior, but you should still code defensively:
- ignore duplicate Running events if a run is already tracked
- tolerate terminal events without a known start time
- treat metrics as eventually consistent
4. Keep labels low-cardinality
Good labels:
- state
- maybe deployment_name
- maybe work_pool
Bad labels:
- flow_run_id
- dynamic user IDs
- timestamps
- arbitrary payload strings
For Prometheus, avoid per-run labels.
5. Authentication/config
The subscriber uses your normal Prefect settings, especially:
- PREFECT_API_URL
- PREFECT_API_KEY for Cloud, if applicable
So the worker process just needs the same auth context it already uses to talk to Prefect.
If you want this more integrated into the worker loop
If you’re modifying a custom worker implementation, you’d usually run this subscriber as a background task alongside the worker’s normal polling loop.
Something like:
python
async def worker_main():
start_http_server(9000)
metrics_task = asyncio.create_task(WorkerMetricsAggregator().run())
worker_task = asyncio.create_task(run_worker())
done, pending = await asyncio.wait(
[metrics_task, worker_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
for task in done:
task.result()
Recommended first version
I’d start with just these metrics:
- prefect_flow_runs_started_total
- prefect_flow_runs_total{state=...}
- prefect_flow_runs_active
- prefect_flow_run_duration_seconds{state=...}
Then add custom event-derived counters only after that’s stable.
One more suggestion
If what you really want is “metrics for runs launched by this specific worker”, you’ll probably want to filter or label by a stable worker-associated dimension, such as:
- work pool
- work queue
- deployment
- maybe a custom tag on deployments
because flow-run events are global, and a generic subscriber may see all runs visible to that API/workspace.
If you want, I can turn this sketch into a more worker-specific example that:
- filters to runs launched by one work pool / queue
- handles startup reconciliation
- and exposes a fuller set of Prometheus metrics.