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
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ Makefiles
dev_context
playground

# Per-developer Pipelex overrides. These files are untracked, so CI builds the published image
# without them — but a local `make docker-build` would otherwise bake one machine's settings into
# the image: its storage backend, its log level, its telemetry credentials. The image would then
# behave differently from the published one, and nothing in the diff would say so. An operator
# supplies overrides to a container by mounting them at /root/.pipelex, the documented path.
#
# The runtime layers more than the `_override` tier over a base file: `_local`, `_{environment}`
# selected by PIPELEX_ENV, and `_temporary_override`, for every configuration family in the
# directory rather than for `pipelex` alone. Environment names are open-ended, so the exclusion is
# by shape — any suffixed variant of a base file — and the one tracked variant is named back in.
# The nested inference overrides need their own line because a pattern's `*` does not cross a `/`.
.pipelex/*_*.toml
!.pipelex/pipelex_service.toml
.pipelex/inference/*_override.toml

# OS / system files
.DS_Store
Thumbs.db
Expand Down
19 changes: 17 additions & 2 deletions .pipelex/pipelex.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,24 @@ signed_urls_lifespan_seconds = 3600 # Set to "disabled
[runtime.log]
# Default logging level: "DEBUG", "INFO", "WARNING", "ERROR"
default_log_level = "INFO"
# Log output target: "stdout" or "stderr"
console_log_target = "stdout"
# The registered log sink this server installs. "json" writes one JSON object per line, with every
# field, the bound run identifiers and the message flat beside each other — the shape a log agent
# in front of a container ingests without a parser. The alternative, "console", is the Rich
# renderer meant for a terminal; this server does not ask for the `cli` extra that declares Rich,
# which is what makes the renderer unused here rather than unavailable — see pretty_print_mode.
sink = "json"
# The stream the json sink writes to: "stdout" or "stderr". Logs are diagnostics and belong off
# the data channel.
console_log_target = "stderr"
console_print_target = "stdout"
# The panels `pretty_print(...)` draws, the "Output of pipe" one after every operator pipe among
# them. A server has no terminal to draw into and must not spend time rendering on the thread
# serving a request, so nothing is printed and no renderable is built. This is what keeps Rich off
# the request path, and it is not the same as keeping it out of the image: `typer` and `instructor`
# are core pipelex dependencies that require Rich unconditionally, so an `import pipelex` loads it
# whatever this file says. Selecting "rich" here, or the "console" sink above, would therefore work
# rather than refuse — these two keys are the whole of what keeps the renderer unused.
pretty_print_mode = "silent"

[runtime.log.package_log_levels]
# Log levels for specific packages (use "-" instead of "." in package names)
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

### Changed

- **The server's logs are structured, one JSON object per line on stderr (Breaking for anything parsing them)**: `[runtime.log] sink = "json"` replaces the Rich console renderer, `console_log_target` moves to `stderr`, and `pretty_print_mode = "silent"` stops an operator pipe drawing its "Output of pipe" panel on the thread serving a request. Every value an error line carries — `route`, `status`, `error_type`, `error_domain`, `retryable`, `user_id` / `pipe_code` / `pipeline_run_id` when the request bound them, and `detail` on the failures this API authors itself — is a key of its own now rather than part of a `key=value` run inside the message, and `request_id` rides the runtime's request-scoped log context, so it lands on every record emitted during a request, including the ones Pipelex emits from inside a run. The message is a short sentence built only from the status and the error type, so no caller-supplied string reaches it and the API's own escaping is gone: the sink is what serializes a value now. A log query matching `event=api_error` as text has to move to the `event` field. The new `docs/logging.md` documents the line and every field on it. Uvicorn's own banner and access log are unchanged and still plain text.
- **Pinned `pipelex` 0.66.0**: up from `==0.65.0`, exactly, the release that carries the structured-log seam the entry above rides on: named fields and the run-scoped log context, the `json` sink selected by `[runtime.log] sink`, the redaction of secrets before any sink sees a record, and Rich behind the `cli` extra, which this server's extras leave out. On this server's lines that means a credential echoed into `detail` reads `[REDACTED]`, a control character in a field's value reads as its printable escape (`\n` where a caller sent a newline), and a line logged inside a traced run carries `trace_id`, `span_id` and `trace_flags`; `docs/logging.md` says so. The `.pipelex/` config shipped here already sits at the schema that release migrates to, so no migration is required. One change reaches an operator beyond the logs: the S3 storage provider now signs links and reads and writes objects on the bucket's own regional host, `<bucket>.s3.<region>.amazonaws.com`, so a deployment whose egress rules allow S3 by hostname must allow `*.s3.<region>.amazonaws.com` (breaking for such a deployment). Nothing on the wire moves.
- **`POST /v1/codegen` stamps `engine_version` `0.66.0`**: the stamp is the pinned `pipelex` version, so a `codegen.lock` committed against `0.65.0` no longer matches until it is regenerated. `POST /v1/build/runner` carries the same stamp.

### Fixed

- **A local image build no longer bakes the builder's own Pipelex overrides in**: `.pipelex/pipelex_override.toml` and `.pipelex/telemetry_override.toml` are untracked per-developer files, so CI never had them, but `make docker-build` copied whatever the developer had into the image — their storage backend, their log level, their telemetry credentials. A locally built image then behaved differently from the published one, with nothing in the diff to say so. `.dockerignore` excludes them; an operator still supplies overrides to a container by mounting them at `/root/.pipelex`.

## [v0.27.5] - 2026-09-25

### Changed
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,21 @@ Every error is rendered as RFC 7807 `application/problem+json` by the global han
- **Domain errors** (pipelex `PipelexError` subclasses) — raise from your code and let them propagate. The `PipelexError` global handler obtains an `ErrorReport` via `to_error_report()` and renders it into a problem document. Do not wrap, classify, or re-shape.
- **API-authored 4xx/5xx** — use the helpers in `api/errors.py`: `raise_validation_error`, `raise_bad_request`, `raise_forbidden`, `raise_unauthenticated`, `raise_payload_too_large`, `raise_internal_server_error`. Each raises an `ApiError` carrying a pre-built problem document; the global handler emits it. **Do not raise `HTTPException` directly** — FastAPI's default handler wraps the body as `{"detail": <whatever>}` and cannot emit a flat RFC 7807 document.
- **Auth errors** — the helpers set `WWW-Authenticate: Bearer` automatically on 401.
- **Logging** — the global handlers emit one structured log line per error (`event=api_error`) with `request_id`, `route`, `error_type`, `error_domain`, `retryable`, `status`, and `user_id` when authenticated. Log disposition follows the final HTTP status: 4xx logs at `warning` (caller mistakes, the provider-429 passthrough, and API-level 4xx overrides like the 409 conflict); 5xx logs at `error` with traceback. Routes should not log error tracebacks themselves.
- **Logging** — the global handlers emit one structured record per error, carrying `event: "api_error"`, `route`, `error_type`, `error_domain`, `retryable`, `status`, and `user_id` / `pipe_code` / `pipeline_run_id` when the request bound them. `detail` rides the API-authored path only: a Pipelex `ErrorReport`'s body text has been through disclosure redaction, so it is not the cause and is deliberately not logged. They travel as `fields=` on the runtime's log call, never interpolated into the message, and `request_id` is not among them: `RequestIdMiddleware` binds it on the runtime's log context, so every record emitted under the request already carries it. The server selects the `json` sink, so a record reaches stderr as one JSON object per line — see `docs/logging.md`. Log disposition follows the final HTTP status: 4xx logs at `warning` (caller mistakes, the provider-429 passthrough, and API-level 4xx overrides like the 409 conflict); 5xx logs at `error` with traceback. Routes should not log error tracebacks themselves.
- **Documenting a failure in OpenAPI** — the shared, typed `responses=` declarations live in `api/openapi_responses.py` (`ProblemDocument` + one constant per status). Every auth-wrapped `/v1` route already documents `401`/`413`/`422`/`500` via the composite router's `responses=` (`api/routes/__init__.py`); a route declares on its own decorator only the statuses **it alone** can produce. Never hand-write an error `content` block: `api/openapi_schema.py` re-keys the generated schema onto `application/problem+json`, because FastAPI renders a response `model` under the route's response-class media type and offers no per-response override. Adding a new status means adding a constant there and referencing it — then `make openapi-export`.

Typical route:

```python
@router.post("/start", response_model=PipelexStartAck, status_code=202)
async def start(
request: Annotated[RunRequest, Depends(request_deserialization)],
request: Request,
run_request: Annotated[RunRequest, Depends(request_deserialization)],
user: Annotated[RequestUser | None, Depends(get_optional_user)],
request_id: Annotated[str, Depends(get_request_id)],
) -> PipelexStartAck:
# Let PipelexError / EnvVarNotFoundError / etc. propagate to the global handler.
return await api_runner.start(request, user=user, request_id=request_id)
# `request_id_of` (api/middleware.py) reads back what RequestIdMiddleware put on the request.
return await api_runner.start(run_request, user=user, request_id=request_id_of(request))
```

For an API-authored failure that has no `PipelexError`:
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ RUN pip install --no-cache-dir uv

WORKDIR /app

# Copy lockfile + project metadata first so dependency-install layer is cacheable
# Copy lockfile + project metadata first so dependency-install layer is cacheable.
# The extras this installs are the ones declared in pyproject.toml, and `cli` is deliberately not
# among them: the server selects the `json` log sink and a Rich-free pretty-print mode, so nothing
# it does on a request renders a terminal. (Rich itself is still in the image — typer and
# instructor both require it unconditionally — it is simply never reached.)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

Expand Down
18 changes: 11 additions & 7 deletions api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from pipelex.base_exceptions import ErrorDomain

from api.error_types import ErrorType
from api.logging_context import get_request_id, get_route_path
from api.problem_document import build_problem_document_from_api_error


Expand All @@ -31,7 +30,9 @@ class ApiError(Exception):
`api.exception_handlers` as `application/problem+json`. Distinct from a pipelex
`PipelexError`: there is no `ErrorReport` behind it — the failure is the
API's own request validation, auth, or configuration check. The problem
document is built at raise time so the handler only has to serialize it.
document is built at raise time, less the request context: `instance` and
`request_id` are stamped by the handler, which is the frame that holds the
`Request`.
"""

def __init__(self, *, status_code: int, document: dict[str, Any], headers: dict[str, str] | None = None) -> None:
Expand All @@ -51,16 +52,19 @@ def _raise_api_error(
) -> NoReturn:
"""Build the RFC 7807 document and raise `ApiError`.

`instance` and `request_id` come from the request-scoped logging
contextvars (`api.logging_context`), bound by `RequestIdMiddleware`, so the
helpers stay parameter-clean and call sites need no `Request`.
The document is built without the request context: `instance` and
`request_id` are stamped by `handle_api_error`, from the `Request` it is
handed. That keeps these helpers parameter-clean — a call site deep inside
a route still needs no `Request` — while leaving the API with no ambient
request state of its own, and it is how the three error paths end up
reading the route and the id from exactly one place.
"""
document = build_problem_document_from_api_error(
error_type,
message,
status,
instance=get_route_path(),
request_id=get_request_id(),
instance=None,
request_id=None,
error_domain=error_domain,
)
raise ApiError(status_code=status, document=document, headers=headers)
Expand Down
Loading
Loading