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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,11 @@ PROMETHEUS_PORT = 1111
JWKS_URI = "https://example.org/jwks"

JWT_AUDIENCE = "some_audience"

# Sentry error monitoring. Optional: leave SENTRY_DSN empty or unset to disable Sentry
# entirely - the application runs normally without it.
SENTRY_DSN=""

SENTRY_ENVIRONMENT="local-development"

SENTRY_TRACES_SAMPLE_RATE=1.0
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ __pycache__/
/.env
/test.db
/test-audit-log-public-key.pem

# all key files
*.pem
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Sentry error monitoring and request tracing, initialised in `src/main.py` before the
FastAPI application is created. Configured by the optional `SENTRY_DSN`,
`SENTRY_ENVIRONMENT`, and `SENTRY_TRACES_SAMPLE_RATE` environment variables; when no
DSN is set the SDK is not initialised and the application runs unchanged, and neither is
it when the DSN is malformed or when running under pytest. Stack frame local variables
and request bodies are deliberately not sent, as they would otherwise transmit bearer
tokens and contact details. The audit log is excluded
entirely, since its records are written at `CRITICAL` and carry the contents of the
`Authorization` header, and a failed startup is now logged rather than printed so that
it is reported as well.

### Changed

### Deprecated
Expand Down
82 changes: 80 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,76 @@ The application is configured using a set of environment variables in a `.env` f
| `AUDIT_LOG_PUBLIC_KEY_PATH` | Path to the public key for encrypting audit logs. |
| `PROMETHEUS_PORT` | Port to serve Prometheus metrics from. |
| `JWT_AUDIENCE` | Audience that we expect to find in JWTs from the identity server. |

| `SENTRY_DSN` | DSN of the Sentry project to report errors to. **Optional** — leave empty or unset and Sentry is not initialised, and the application runs normally. |
| `SENTRY_ENVIRONMENT` | Environment name that events are tagged with in Sentry, e.g. `"dev"` or `"prod"`. Defaults to `"local-development"` rather than the SDK's own default of `"production"`, so that an unconfigured environment can never be mistaken for the live one. |
| `SENTRY_TRACES_SAMPLE_RATE` | Proportion of requests traced for performance monitoring, `0.0`–`1.0`. Defaults to `1.0`; lower it (e.g. `0.1`) if trace volume becomes a problem. |

### Error monitoring with Sentry

Sentry is initialised in `src/main.py` by `register_your_data_api.sentry.setup_sentry()`,
before the FastAPI application object is created, so that its Starlette/FastAPI
integrations are in place. It reports unhandled exceptions and traces requests.

Configuration is read directly from the environment rather than through `Context`,
because `Context` is not created until the application lifespan runs, which is after the
SDK has to be initialised. Sentry is optional by design: error reporting should never be
the reason the API fails to start, so a missing DSN disables it rather than raising, and
so does a DSN the SDK rejects as malformed — `sentry_sdk.init` raises `BadDsn` for those,
which would otherwise stop the process at import time before there is a logger to report
it to.

The test suite must not report to a real project. It imports `src/main.py` through
`tests/helpers/mocking.py`, so `setup_sentry()` runs during collection and a developer's
`.env` would otherwise point the suite's deliberately-provoked errors — and a transaction
per request — at whichever project that DSN names. `tests/conftest.py` empties
`SENTRY_DSN` for the session, which is the SDK's own way of being switched off.

`include_local_variables=False` because otherwise Sentry would attach the local
variables of every stack frame to an event, and this application's credentials reach
the stack in forms which cannot all be recognised by name: an ASGI frame's locals hold
the raw `scope`/`request` objects, whose headers are a list of `(bytes, bytes)` tuples
rather than a named field, and the bearer token is a local variable in `auth/authn.py`
in its own right as well as a field of `UserAndCredentials`. Local variables are
therefore not sent at all, which costs the variable values in a traceback but keeps
the file, line, function and source line of every frame. With frame locals enabled
a live bearer token is transmitted in the event payload .

`send_default_pii=False` keeps cookies and the client IP address out of events, and the
`EventScrubber` denylist in `sentry.py` withholds this application's own secrets by name
wherever the SDK collects them by other means.

Be careful about what `send_default_pii=False` does **not** do, because the option name
suggests more than it delivers:

* It does not keep request **headers** out of events. The SDK substitutes the sensitive
ones and passes the rest through, so `host`, `user-agent` and `content-type` are sent.
The `Authorization` header *is* withheld by this option, though — `_filter_headers` in
the SDK returns every header untouched when PII is on, and substitutes its
`SENSITIVE_HEADERS` only when it is off. The `EventScrubber` is not what protects the
bearer token, so trimming its denylist would not expose the token — but turning
`send_default_pii` on would.
* It does not keep request **bodies** out of events — the SDK collects those regardless of
it, bounded by `max_request_body_size`. Because tracing is enabled the body rides out
on the transaction event for **successful** requests too, so a `POST` creating a
reporting org would have transmitted its `contact_email` on every call.
`max_request_body_size="never"` is what actually withholds them.

Deciding that a variable holds a credential is still a manual step:
`tests/unit/test_sentry.py::test_sentry_scrubs_every_configuration_variable_which_looks_like_a_secret`
is a backstop that catches the common cases by name fragment, but a credential with an
unremarkable name would satisfy it.

**The audit log is never sent to Sentry.** Sentry turns any log record of `ERROR` or
above into an event, and the audit log is written at `CRITICAL` for authentication
failures — where `auth/authn.py` records the contents of the `Authorization` header,
including the credential itself, for a request whose scheme is not `bearer`. Sending
those records to a third party in plain text would defeat the point of encrypting the
audit log at rest, so `sentry.py` calls both `ignore_logger()` and
`ignore_logger_for_sentry_logs()` for it — the SDK keeps two separate ignore lists and
the first covers only events and breadcrumbs. Sentry Logs are off, so the second call
changes nothing today; it is there so that enabling them later cannot silently start
sending the audit log. The application's diagnostic log is still reported, which is
how application code reports errors without referencing the SDK.

### FineGrainedAuthorisation database migrations

Expand Down Expand Up @@ -123,7 +192,7 @@ Care should be taken to make sure that the `.env` variables match the log (`APP_
New dependencies are added to `pyproject.toml`. Once these have been added `requirements.txt` and/or `requirements_dev.txt` need to be regenerated. With:

```
pip-compile --output-file=requirements.txt --strip-extras
pip-compile --all-build-deps --strip-extras
```

and/or
Expand All @@ -132,6 +201,15 @@ and/or
pip-compile --extra=dev --output-file=requirements_dev.txt --strip-extras
```

Note that the two commands take different options, so run each as given above rather than
applying one set of flags to both files. The command line recorded at the top of each
generated file is the authoritative record of how that file was built.

**Regenerate on Linux, not on macOS.** `pip-compile` resolves for the platform it runs on and
has no cross-platform mode, so a macOS run silently drops dependencies that the deployment
target needs. SQLAlchemy, for example, requires `greenlet` on `x86_64` and `aarch64` but not
on Apple Silicon's `arm64`, so a run on an M-series Mac omits it and loses the pin.

### Checking and linting

Linting is setup with `isort` and `black` and checked with `flake8`. Static type checking is performed by `mypy`. Configurations are stored in `pyproject.toml`. To use these linters and checkers you will first need to install the development dependencies:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"python-dotenv>=1.1.1",
"psycopg[binary]>3",
"requests==2.32.4",
"sentry-sdk~=2.35",
"sqlmodel==0.0.25",
"SQLAlchemy==2.0.43",
"types-requests==2.32.4.20250611"
Expand Down
6 changes: 4 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,10 @@ rich-toolkit==0.14.9
# fastapi-cloud-cli
rignore==0.6.4
# via fastapi-cloud-cli
sentry-sdk==2.34.1
# via fastapi-cloud-cli
sentry-sdk==2.68.1
# via
# fastapi-cloud-cli
# register-your-data-api (pyproject.toml)
shellingham==1.5.4
# via typer
sniffio==1.3.1
Expand Down
6 changes: 4 additions & 2 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,10 @@ rich-toolkit==0.14.9
# fastapi-cloud-cli
rignore==0.6.4
# via fastapi-cloud-cli
sentry-sdk==2.34.1
# via fastapi-cloud-cli
sentry-sdk==2.68.1
# via
# fastapi-cloud-cli
# register-your-data-api (pyproject.toml)
shellingham==1.5.4
# via typer
smmap==5.0.2
Expand Down
18 changes: 16 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Register Your Data API"""

import contextlib
import logging
import sys
from typing import AsyncIterator

Expand All @@ -10,6 +11,7 @@
import register_your_data_api.exception_handlers
import register_your_data_api.util as util
from register_your_data_api.routers import datasets, discoverable_reporting_orgs, misc, reporting_orgs, users
from register_your_data_api.sentry import setup_sentry


@contextlib.asynccontextmanager
Expand All @@ -19,8 +21,14 @@ async def prod_lifespan(app: FastAPI) -> AsyncIterator[None]:
context.setup()
prometheus_client.start_http_server(int(context.env["PROMETHEUS_PORT"]))

except Exception as err:
print(f"Could not initialise application - error setting up context {err}")
except Exception:
# Logged rather than printed so that it reaches error monitoring: a service which
# will not start is the failure most worth being told about, and SystemExit is a
# BaseException, so nothing downstream of this would report it. With no handlers
# configured — Context, and therefore the application logger, is what just failed —
# logging still writes the message and traceback to stderr via its last-resort
# handler. The SDK flushes on process exit.
logging.getLogger(__name__).exception("Could not initialise application - error setting up context")
sys.exit("Could not startup")

app.state.context = context
Expand All @@ -37,6 +45,12 @@ def add_routers_and_general_exception_handling(app: FastAPI) -> None:
register_your_data_api.exception_handlers.add_exception_handlers(app)


# Sentry must be initialised before the application object below is created, so that its
# Starlette/FastAPI integrations are in place for it. The integrations patch modules that
# are already imported, so only the object's creation has to come after this, not the
# imports. A no-op when no DSN is configured.
setup_sentry()

app = FastAPI(title="Register Your Data", lifespan=prod_lifespan, redirect_slashes=False)

add_routers_and_general_exception_handling(app)
Loading
Loading