Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,6 @@ mail_config.toml

/logs/
logs/*
logs
logs

PR_DESCRIPTION.md
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,39 @@ substring instead. This is useful for deep-debugging code that runs through
several layers of internal libraries or wrappers, where the default
outermost-user-frame would land too high up the call stack.

### `WebhookChannel`

```python
WebhookChannel(
webhook_config: WebhookConfig,
)
```

```python
WebhookConfig(
url: str,
headers: dict[str, str] = {}, # merged with default Content-Type: application/json
timeout: int = 5,
secret: str | None = None, # HMAC-SHA256 signs the body when set
extra_payload: dict[str, Any] = {}, # extra top-level keys merged into the JSON body
)
```

POSTs a generic JSON payload to `url` on `logging.ERROR` and above —
`task_name`, `function`, `args`, `kwargs`, `exception`, `traceback`,
`downstream_impact`, `traced_vars`, and `traced_vars_location`. Not coupled
to any specific service (Slack, Discord, etc.); subclass and override
`_WebhookFormatter._build_payload` to reshape the payload for one.

`extra_payload` keys are merged into the JSON body and take precedence over
the generic fields on collision — useful for service-specific routing data
(e.g. a Telegram `chat_id` or a Slack `channel`/`username` override) without
subclassing.

If `secret` is set, the request carries an `X-Signature-SHA256` header with
the hex-encoded `hmac.new(secret, body, hashlib.sha256)` digest of the JSON
body, so receivers can verify the payload wasn't tampered with.

</details>

<details>
Expand Down
2 changes: 2 additions & 0 deletions src/processes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
)
from .notification_channels import EmailChannel as EmailChannel
from .notification_channels import NotificationChannel as NotificationChannel
from .notification_channels import WebhookChannel as WebhookChannel
from .process import Process as Process
from .task import Task as Task
from .task import TaskDependency as TaskDependency
from .task import TaskResult as TaskResult
from .webhook_config import WebhookConfig as WebhookConfig

try:
__version__ = _v("processes")
Expand Down
7 changes: 6 additions & 1 deletion src/processes/_email_internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ def format(self, record: logging.LogRecord) -> str:
f"<li>{html.escape(str(name), quote=True)}</li>" for name in error.downstream_impact
)

traced_vars_html = "\n".join(
html.escape(f"{name} = {value}", quote=True)
for name, value in error.traced_vars.items()
)

substitutions = dict(_load_language_strings(self._email_language))
substitutions["lang_traced_vars_blurb"] = substitutions.get(
"lang_traced_vars_blurb", ""
Expand All @@ -141,7 +146,7 @@ def format(self, record: logging.LogRecord) -> str:
"traceback_before": html.escape(tb_before, quote=True),
"traceback_highlight": html.escape(tb_highlight, quote=True),
"traceback_after": html.escape(tb_after, quote=True),
"traced_vars": error.traced_vars,
"traced_vars": traced_vars_html,
"downstream_items": downstream_items,
}
)
Expand Down
9 changes: 5 additions & 4 deletions src/processes/_error_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ class _ErrorData:
String representation of the raised exception. Defaults to ``""``.
traceback_str : str
Full formatted traceback. Defaults to ``""``.
traced_vars : str
Rendered local variables of the traced frame. Defaults to ``""``.
traced_vars : dict[str, str]
Mapping of local variable names to ``repr(value)`` for the traced
frame. Defaults to ``{}``.
traced_vars_location : str
``"filename:lineno"`` of the traced frame. Defaults to ``""``.
"""
Expand All @@ -38,7 +39,7 @@ class _ErrorData:
downstream_impact: list[str] = field(default_factory=list)
exception: str = ""
traceback_str: str = ""
traced_vars: str = ""
traced_vars: dict[str, str] = field(default_factory=dict)
traced_vars_location: str = ""


Expand Down Expand Up @@ -69,6 +70,6 @@ def _error_data(self, record: logging.LogRecord) -> _ErrorData:
downstream_impact=ctx.get("downstream_impact", []) or [],
exception=ctx.get("exception", record.getMessage()),
traceback_str=ctx.get("traceback_str", ""),
traced_vars=ctx.get("traced_vars", ""),
traced_vars=ctx.get("traced_vars", {}) or {},
traced_vars_location=ctx.get("traced_vars_location", ""),
)
7 changes: 5 additions & 2 deletions src/processes/_logfile_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ class _TaskLogfileFormatter(_ErrorContextFormatter):
"""Plain-text formatter for task logfiles.

On failure, appends the same failure context shown in the HTML email
(function, args, kwargs, downstream impact, traced-vars location,
traceback) as readable text.
(function, args, kwargs, downstream impact, traced variables and their
location, traceback) as readable text.
"""

def __init__(self) -> None:
Expand Down Expand Up @@ -51,6 +51,9 @@ def format(self, record: logging.LogRecord) -> str:
f"Downstream impact: {', '.join(error.downstream_impact) or '-'}",
f"Traced vars location: {error.traced_vars_location or '-'}",
]
if error.traced_vars:
lines.append("Traced vars:")
lines.extend(f" {name} = {value}" for name, value in error.traced_vars.items())
if error.traceback_str:
lines.append("")
lines.append(error.traceback_str.rstrip("\n"))
Expand Down
22 changes: 10 additions & 12 deletions src/processes/_tb_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import html
import os
import sys
import traceback
Expand Down Expand Up @@ -86,8 +85,8 @@ def _resolve_target_tb(exc_tb: Any, frame_filter: str | None) -> Any:
return matches[-1] if matches else frames[-1]


def _build_traced_vars_html(exc_tb: Any, frame_filter: str | None) -> str:
"""Return HTML-escaped ``name = repr(value)`` lines for the target frame's locals.
def _build_traced_vars(exc_tb: Any, frame_filter: str | None) -> dict[str, str]:
"""Return ``{name: repr(value)}`` for the target frame's local variables.

Parameters
----------
Expand All @@ -99,22 +98,21 @@ def _build_traced_vars_html(exc_tb: Any, frame_filter: str | None) -> str:

Returns
-------
str
Newline-separated, HTML-escaped ``name = repr(value)`` lines for the
target frame's locals, or ``""`` if no frame is found.
dict[str, str]
Mapping of local variable names to ``repr(value)`` for the target
frame, in definition order, or ``{}`` if no frame is found.
"""
target = _resolve_target_tb(exc_tb, frame_filter)
if target is None:
return ""
return {}
frame = target.tb_frame
lines: list[str] = []
traced_vars: dict[str, str] = {}
for name, value in frame.f_locals.items():
try:
rendered = repr(value)
traced_vars[name] = repr(value)
except Exception as exc:
rendered = f"<unreprable: {type(exc).__name__}: {exc}>"
lines.append(f"{name} = {rendered}")
return "\n".join(html.escape(line, quote=True) for line in lines)
traced_vars[name] = f"<unreprable: {type(exc).__name__}: {exc}>"
return traced_vars


def _build_traced_vars_location(exc_tb: Any, frame_filter: str | None) -> str:
Expand Down
114 changes: 114 additions & 0 deletions src/processes/_webhook_internals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from __future__ import annotations

import hashlib
import hmac
import json
import logging
import urllib.request
from typing import Any

from ._error_data import _ErrorContextFormatter, _ErrorData
from .webhook_config import WebhookConfig

_SIGNATURE_HEADER = "X-Signature-SHA256"


class _WebhookFormatter(_ErrorContextFormatter):
"""Pure renderer: builds a generic JSON payload from ``record.task_context``."""

def __init__(self, extra_payload: dict[str, Any] | None = None) -> None:
super().__init__()
self._extra_payload = extra_payload or {}

def format(self, record: logging.LogRecord) -> str:
"""Render a log record as a JSON payload string.

Parameters
----------
record : logging.LogRecord
The record being formatted.

Returns
-------
str
A JSON-encoded object describing the task failure, merged with
any configured ``extra_payload`` keys (which take precedence on
collision).
"""
error = self._error_data(record)
payload = {**self._build_payload(error), **self._extra_payload}
return json.dumps(payload)

def _build_payload(self, error: _ErrorData) -> dict[str, Any]:
"""Build the JSON-serializable payload dict from ``_ErrorData``.

Subclasses targeting a specific webhook service can override this
to reshape the payload, while reusing ``format`` and the rest of
the channel/handler machinery.

Parameters
----------
error : _ErrorData
Typed failure context for the record being formatted.

Returns
-------
dict[str, Any]
JSON-serializable payload.
"""
return {
"task_name": error.task_name,
"function": error.function,
"args": repr(error.args),
"kwargs": repr(error.kwargs),
"exception": error.exception,
"traceback": error.traceback_str,
"downstream_impact": list(error.downstream_impact),
"traced_vars": error.traced_vars,
"traced_vars_location": error.traced_vars_location,
}


class _WebhookHandler(logging.Handler):
"""Internal handler that POSTs formatted log records as JSON."""

def __init__(self, config: WebhookConfig) -> None:
super().__init__()
self._config = config

def emit(self, record: logging.LogRecord) -> None:
try:
body = self.format(record).encode("utf-8")
headers = {"Content-Type": "application/json", **self._config.headers}
if self._config.secret is not None:
digest = hmac.new(
self._config.secret.encode("utf-8"), body, hashlib.sha256
).hexdigest()
headers[_SIGNATURE_HEADER] = digest

request = urllib.request.Request(
self._config.url, data=body, headers=headers, method="POST"
)
with urllib.request.urlopen(request, timeout=self._config.timeout):
pass
except Exception:
self.handleError(record)


def _build_task_webhook_handler(config: WebhookConfig) -> _WebhookHandler:
"""Create a fully configured webhook handler.

Parameters
----------
config : WebhookConfig
Webhook transport configuration for the handler.

Returns
-------
_WebhookHandler
A handler at ``logging.ERROR`` level with a ``_WebhookFormatter``.
"""
handler = _WebhookHandler(config)
handler.setFormatter(_WebhookFormatter(extra_payload=config.extra_payload))
handler.setLevel(logging.ERROR)
return handler
43 changes: 43 additions & 0 deletions src/processes/notification_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from ._email_internals import _build_task_email_handler
from ._logfile_formatting import _TaskLogfileFormatter
from ._webhook_internals import _build_task_webhook_handler
from .email_config import HTMLEmailStyle, SMTPConfig
from .webhook_config import WebhookConfig


class NotificationChannel(ABC):
Expand Down Expand Up @@ -144,3 +146,44 @@ def frame_filter(self) -> str | None:
The configured ``traced_vars_frame_filter``, or ``None``.
"""
return self.style.traced_vars_frame_filter


class WebhookChannel(NotificationChannel):
"""Notification channel that POSTs a JSON alert to a webhook URL on task failure.

The JSON payload is built generically from the task's failure context
(function, args/kwargs, exception, traceback, downstream impact, traced
variables), so it can be consumed directly or transformed by downstream
relays (e.g. Slack/Discord/Teams webhook adapters, custom alerting
servers). It is not coupled to any specific service.

Attributes
----------
webhook_config : WebhookConfig
Webhook transport configuration for the alert.

Parameters
----------
webhook_config : WebhookConfig
Webhook transport configuration for the alert.
"""

def __init__(self, webhook_config: WebhookConfig):
self.webhook_config = webhook_config

def build_handler(self, task_name: str) -> logging.Handler:
"""Build a JSON webhook handler.

Parameters
----------
task_name : str
Name of the task the handler will be attached to. Unused by
this channel, accepted for interface consistency.

Returns
-------
logging.Handler
A handler at ``logging.ERROR`` level that POSTs a JSON payload
describing the failure for each error log record.
"""
return _build_task_webhook_handler(self.webhook_config)
4 changes: 2 additions & 2 deletions src/processes/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import logging

from ._tb_utils import _build_traced_vars_html, _build_traced_vars_location, _format_traceback
from ._tb_utils import _build_traced_vars, _build_traced_vars_location, _format_traceback
from .exceptions import CircularDependencyError
from .notification_channels import NotificationChannel, _FileChannel

Expand Down Expand Up @@ -367,7 +367,7 @@ def _build_failure_context(
"downstream_impact": downstream_names,
"exception": str(exc),
"traceback_str": _format_traceback(exc),
"traced_vars": _build_traced_vars_html(exc_tb, self._frame_filter),
"traced_vars": _build_traced_vars(exc_tb, self._frame_filter),
"traced_vars_location": _build_traced_vars_location(exc_tb, self._frame_filter),
}

Expand Down
Loading
Loading