Skip to content

Telemetry

Winslow can report every error of a run to an external backend, together with the context an operator needs to act on it: the workflow, the session, the task and its parameters, the batch and the execution phase.

Two backends ship with Winslow, Sentry and OpenTelemetry, and both follow the same five steps: install the extra, configure the environment, run the workflow, observe the errors in the backend, and customize by subclassing when the defaults are not enough. There is no bootstrap code at any step. This page walks both backends through these steps, explains how activation works underneath, and ends with the seam for writing your own backend.

What is reported

A backend receives defects, and only defects:

  • an exception that escapes a step of a task (the task status becomes ERROR),
  • sys.exit() inside task code, and a task signal that no ladder consumes,
  • a workflow that does not start (EligibilityError, InitializationError),
  • a session that fails outside a batch.

Expected outcomes stay out: a failed check and a task signal (skip, block, fail, action required) are verdicts, not defects.

Set up a failing task

Every example on this page reports the same defect: a parameterized task that raises in run(). To follow along, add it to the etl workflow:

workflows/etl/tasks/boom.py
from winslow import Parameter, Task


class Boom(Task):
    region = Parameter(values=("eu", "us"))

    def run(self):
        raise RuntimeError(f"boom in {self.region}")

    def check(self):
        return False  # The work is never done, so run() always executes.

Sentry

The Sentry backend turns each error into one Sentry event.

Install the extra

uv add "winslow[sentry]"
pip install 'winslow[sentry]'

Configure the environment

Set SENTRY_DSN; that is the whole integration. With the DSN present, the built-in SentryConfiguration activates for each run and flushes its events before the process exits (see How activation works).

Environment value Purpose When unset
SENTRY_DSN Activates the backend and selects the project. The backend stays inactive.
SENTRY_ENVIRONMENT The environment tag of each event. Falls back to WINSLOW_ENV.
SENTRY_RELEASE The release of each event. The SDK detects it (git, CI values).

Export the values, or put them in a .env file: the search for the file starts in the working directory and walks up its parents, and an exported variable always wins over the file (python-decouple).

Run the workflow

export SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0"
winslow run --mode headless --workflow etl

Observe the events

The run reports one event per parameter row of Boom: the Sentry project shows two new issues, boom (eu) and boom (us). Each event carries these tags to search and filter on (the environment arrives through the native SDK field, not a tag):

Tag Value Example
workflow The declared workflow name. etl
workflow_class The workflow class name. Etl
workflow_instance str(workflow): the name plus the identifier options of the run. etl (client=acme)
task The declared task name. boom
task_class The task class name. Boom
task_instance str(task): the name plus the parameter values. boom (eu)
groups The groups of the task, comma-joined; absent without groups. mild
session_id The id of the execution session. etl-20260808T…
batch_uuid The id of the batch, one run or check action. 39b83ad0-…
phase The execution phase of the errored step. run

Beyond the tags, each event carries the parameter values and the identifier options as extra data, and the run log records that led to the error as breadcrumbs.

Sentry folds events into one issue when their fingerprints match. Winslow fingerprints an error with the workflow instance, the task instance and the exception type: boom (eu) and boom (us) become two separate issues, and so do two runs of the workflow with different --client values. The grouping is this fine because such a failure is usually a config or data error of one specific combination, and one issue per task class would hide which combination fails.

A workflow that does not start reports the same way, with the workflow and session tags only.

Customize: report from production only

The built-in activates wherever the DSN is set. To decide differently, subclass SentryConfiguration in a telemetry.py file and override get_handler: the subclass replaces the built-in, and nothing else changes.

telemetry.py
from winslow import settings
from winslow.contrib.sentry import SentryConfiguration


class ProdOnlySentry(SentryConfiguration):
    """Reports from production only, also where a DSN is set."""

    def get_handler(self, orchestrator_config):
        if settings.env != "prod":
            return None
        return super().get_handler(orchestrator_config)

Run the workflow again: no events arrive, because WINSLOW_ENV defaults to dev. With WINSLOW_ENV=prod the events flow as before.

OpenTelemetry

The OpenTelemetry backend turns each error into one short span with the exception recorded on it.

Install the extra

uv add "winslow[otel]"
pip install 'winslow[otel]'

Configure the endpoint

Set an OTLP traces endpoint; that is the whole integration. With an endpoint present, the built-in OpenTelemetryConfiguration activates for each run and flushes its spans before the process exits (see How activation works). OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is used exactly as given; the more general OTEL_EXPORTER_OTLP_ENDPOINT gets /v1/traces appended, following the OTLP http convention. Both are read through python-decouple, like the Sentry values.

Run the workflow

export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4318/v1/traces"
winslow run --mode headless --workflow etl

Observe the spans

The run exports one span per errored Boom instance: the span is named winslow.task_error, its status is ERROR, and the exception is recorded on it as the standard exception event. A workflow that does not start exports the same shape under the name winslow.unscoped_error.

Look for the spans under the service name winslow (set OTEL_SERVICE_NAME to change it). The service identifies the winslow process, not a workflow: one process can run many workflows, and the spans of all of them share its service name. To select one workflow, filter on the span attributes:

Attribute Value Example
winslow.workflow The declared workflow name. etl
winslow.workflow_class The workflow class name. Etl
winslow.workflow_instance str(workflow): the name plus the identifier options of the run. etl (client=acme)
winslow.task The declared task name. boom
winslow.task_class The task class name. Boom
winslow.task_instance str(task): the name plus the parameter values. boom (eu)
winslow.parameter.<name> One attribute per parameter of the task. winslow.parameter.region: eu
winslow.identifier.<name> One attribute per identifier option of the run. winslow.identifier.client: acme
winslow.groups The groups of the task; absent without groups. ["mild"]
winslow.session_id The id of the execution session. etl-20260808T…
winslow.batch_uuid The id of the batch, one run or check action. 39b83ad0-…
winslow.phase The execution phase of the errored step. run
winslow.env WINSLOW_ENV. prod

Customize: replace the tracer provider

The built-in builds its tracer provider from the environment alone. To change the exporter, the resource or the processor, subclass OpenTelemetryConfiguration in a telemetry.py file and override get_tracer_provider. This example replaces the service name and stamps the owning team onto the resource, so every span carries both:

telemetry.py
from decouple import config
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor

from winslow.contrib.otel import OpenTelemetryConfiguration


class TaggedTelemetry(OpenTelemetryConfiguration):
    """Stamps the service name and the owning team onto every span."""

    def get_tracer_provider(self):
        endpoint = config("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", default=None)
        if endpoint is None:
            return None  # stay inactive without an endpoint

        # shutdown_on_exit stays off: the configuration owns the flush, and
        # the default atexit hook would shut the provider down twice.
        provider = TracerProvider(
            resource=Resource.create(
                {"service.name": "data-pipelines", "team": "data-platform"}
            ),
            shutdown_on_exit=False,
        )
        provider.add_span_processor(
            SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
        )
        return provider

Run the workflow again: every span now carries the team resource attribute. For a change beyond the provider, other span names or other attributes, override get_handler instead.

How activation works

Both backends activate configuration first. When the environment carries their values, a DSN for Sentry or an OTLP endpoint for OpenTelemetry, the backend activates on its own for each run: no class, no import, no bootstrap code. Without the values, the backend stays inactive and adds no cost.

An active backend is safe for the run itself: a backend that fails while reporting is logged and never reaches the batch.

When the defaults are not enough, the repo declares a subclass of the built-in configuration class in a telemetry.py file. Winslow collects these files the way it collects workflow.py files, and among the collected classes only the most derived one activates. A subclass therefore replaces the built-in it inherits from, and never doubles the reports.

A configured backend needs its extra installed

When the environment activates a backend whose extra is not installed, the run does not start (MisconfigurationError).

Write a custom backend

The built-ins cover Sentry and OpenTelemetry, but the seam behind them is open. To add a backend next to the built-ins, declare a direct subclass of TelemetryConfiguration in a telemetry.py file: the configuration decides when its backend is active and builds the handler, and the handler consumes the errors.

telemetry.py
from winslow import settings
from winslow.telemetry import TelemetryConfiguration, TelemetryHandler


class PagerHandler(TelemetryHandler):
    def on_task_error(self, workflow, task, exc, batch_uuid, phase):
        ...  # page the on-call

    def on_unscoped_error(self, exc, **context):
        ...


class PagerTelemetry(TelemetryConfiguration):
    def get_handler(self, orchestrator_config):
        if settings.env != "prod":
            return None  # page from production only
        return PagerHandler()

One rule binds a handler: it runs on the worker thread that hit the error, so it must not block.

A host application that embeds winslow and owns its process does not need the configuration seam: it registers a handler directly with winslow.telemetry.register_error_handler, or calls the setup functions of the contrib modules.