edutap.observability-settings 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ # Nothing here is locked: uv.lock is git-ignored and every requirement is a lower
7
+ # bound. A suite that passed last week can fail today with nothing changed here.
8
+ schedule:
9
+ - cron: "17 5 * * 1"
10
+ # Callable, so the release workflow runs *these* checks rather than its own copy.
11
+ # A release gate that drifts from CI is a gate that passes what CI would have
12
+ # stopped -- and it drifts silently, because nobody reads two files side by side.
13
+ workflow_call:
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ python: ["3.13", "3.14"]
20
+ steps:
21
+ - uses: actions/checkout@v5
22
+ - uses: astral-sh/setup-uv@v7
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - run: uv venv
26
+ - run: uv pip install -e ".[dev]"
27
+ - run: .venv/bin/python -m pytest -v
28
+ lint:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v5
32
+ - uses: astral-sh/setup-uv@v7
33
+ with:
34
+ python-version: "3.13"
35
+ - run: uv venv
36
+ - run: uv pip install -e ".[dev]"
37
+ - run: .venv/bin/python -m ruff check src tests
38
+ - run: .venv/bin/python -m ruff format --check src tests
39
+ - run: .venv/bin/python -m ty check src
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: Build & upload PyPI package
3
+
4
+ on:
5
+ push:
6
+ branches: [main]
7
+ tags: ["*"]
8
+ release:
9
+ types:
10
+ - published
11
+ workflow_dispatch:
12
+
13
+ # Without ``github.event_name`` in the group, the ``push`` event fired
14
+ # by tagging a release and the ``release`` event fired by publishing
15
+ # it land in the same group. ``cancel-in-progress: true`` then kills
16
+ # whichever arrived second — typically the ``release`` run that
17
+ # uploads to pypi.org, leaving the tag unpublished.
18
+ concurrency:
19
+ group: release-${{ github.ref }}-${{ github.event_name }}
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ # The repository's own CI, called rather than copied. A release gate that
24
+ # restates the checks drifts from them, and it drifts silently.
25
+ tests:
26
+ uses: "./.github/workflows/ci.yml"
27
+
28
+ # Always build & lint package.
29
+ build-package:
30
+ name: Build & verify package
31
+ needs:
32
+ - tests
33
+ runs-on: ubuntu-latest
34
+ permissions:
35
+ attestations: write
36
+ id-token: write
37
+
38
+ steps:
39
+ - uses: actions/checkout@v7
40
+ with:
41
+ fetch-depth: 0
42
+ persist-credentials: false
43
+
44
+ - uses: hynek/build-and-inspect-python-package@v3.0.1
45
+ with:
46
+ attest-build-provenance-github: 'true'
47
+
48
+ # Upload to Test PyPI on every commit on main.
49
+ release-test-pypi:
50
+ name: Publish in-dev package to test.pypi.org
51
+ environment: release-test-pypi
52
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
53
+ runs-on: ubuntu-latest
54
+ needs:
55
+ - build-package
56
+ permissions:
57
+ id-token: write
58
+
59
+ steps:
60
+ - name: Download packages built by build-and-inspect-python-package
61
+ uses: actions/download-artifact@v8
62
+ with:
63
+ name: Packages
64
+ path: dist
65
+
66
+ - name: Upload package to Test PyPI
67
+ uses: pypa/gh-action-pypi-publish@release/v1
68
+ with:
69
+ repository-url: https://test.pypi.org/legacy/
70
+
71
+ # Upload to real PyPI on GitHub Releases.
72
+ release-pypi:
73
+ name: Publish released package to pypi.org
74
+ environment: release-pypi
75
+ if: github.event.action == 'published'
76
+ runs-on: ubuntu-latest
77
+ needs:
78
+ - build-package
79
+ permissions:
80
+ id-token: write
81
+
82
+ steps:
83
+ - name: Download packages built by build-and-inspect-python-package
84
+ uses: actions/download-artifact@v8
85
+ with:
86
+ name: Packages
87
+ path: dist
88
+
89
+ - name: Upload package to PyPI
90
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .env
11
+ docs/_build/
12
+ uv.lock
13
+
14
+ .superpowers/
15
+
16
+ # mutmut copies the whole project here to run the suite against each mutant.
17
+ mutants/
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.16.0
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: https://github.com/pre-commit/pre-commit-hooks
9
+ rev: v6.0.0
10
+ hooks:
11
+ - id: trailing-whitespace
12
+ - id: end-of-file-fixer
13
+ - id: check-yaml
14
+ - id: check-toml
@@ -0,0 +1,85 @@
1
+ # CLAUDE.md — edutap.observability_settings
2
+
3
+ Repository-specific rules. They take precedence over the global defaults.
4
+
5
+ ## Language
6
+
7
+ **English only.** This repository belongs to eduTAP proper, not to any single
8
+ institution: README, changelog, documentation, docstrings, code comments, commit
9
+ messages, pull request titles and bodies, and replies to review comments.
10
+
11
+ The language follows the repository, not the conversation. A discussion held in
12
+ German still produces English artefacts here.
13
+
14
+ ## What this package is
15
+
16
+ Error reporting, tracing and structured logging, wired the same way in every eduTAP
17
+ service, plus the decision about what a service may say about a person. It is
18
+ installed before a service resolves its own settings.
19
+
20
+ ## Guard rails
21
+
22
+ **Every option here is a decision about what leaves the process.** The Sentry options
23
+ are not defaults someone liked; each was chosen against a measurement and each
24
+ contradicts the backend's own recommendation. Changing one means repeating the
25
+ measurement and recording it, not reasoning about it.
26
+
27
+ **`pseudonym` without a salt yields nothing, never the raw value.** A deployment that
28
+ asked for pseudonyms and forgot the key has to lose the datum. Any code path that
29
+ falls back to the plain `person_uid` defeats the whole package.
30
+
31
+ **`send_to_logfire` stays `False`.** The library defaults it to `True`. This estate
32
+ exports to its own collector, and an unset value ships spans to a hosted third party
33
+ the first time a token happens to be present.
34
+
35
+ **Never import from an eduTAP service.** This is installed before a service resolves
36
+ its settings; it can know nothing about them. Depending on `edutap.data_models` is
37
+ the one exception, and it is a library.
38
+
39
+ **Options are returned before they are applied.** `sentry_options` and
40
+ `logfire_options` are pure so a test can assert the exact set rather than assert that
41
+ something was configured. Do not inline them into the `init` call.
42
+
43
+ **No `uv.lock`.** This is a library; pinning here would push a resolution onto every
44
+ consumer.
45
+
46
+ ## Working practice
47
+
48
+ Branch first, never commit on `main`. Push only when asked. `make lint` and
49
+ `make test-local` green before opening a pull request.
50
+
51
+ Design records live under `docs/superpowers/`. They are records of a decision at a
52
+ point in time — do not rewrite them to match a later state; write a new one.
53
+
54
+ ## Sources and confidentiality
55
+
56
+
57
+ **No vendor internals — from any vendor, not just the ones currently in play.**
58
+ Neither in files nor in commit messages.
59
+
60
+ The standard is academic: a statement counts as reliable only where it can be
61
+ evidenced from public information, with a link. Everything else was obtained either
62
+ by our own testing or through insider knowledge, and the three are not
63
+ interchangeable:
64
+
65
+ * **Documented** — public source, linked. May be written as fact.
66
+ * **Verified, not citable** — obtained by a person from an access-protected area and
67
+ checked there; the reference is recorded internally but must not be published; and
68
+ the statement has been reduced to what is not confidential. May be written as fact,
69
+ carrying this label. It is the rule journalism uses for source protection: the claim
70
+ stands, we know where it comes from, the reader does not get the source.
71
+
72
+ The four conditions hold together. A statement for which nobody can name the
73
+ internal reference does not fall here — that is insider knowledge.
74
+ * **Measured** — established by our own tests. May be written down, but always marked
75
+ as such, because it describes what a platform did on the day we looked, not what it
76
+ guarantees. It can change with the next release, without notice and without an
77
+ entry in any changelog.
78
+ * **Insider knowledge** — is not written down at all.
79
+
80
+ What a platform's behaviour *means for us* stays documentable even where the
81
+ mechanism does not: "the platform enforces a deadline, it is self-healing, it is
82
+ outside our control" carries the design consequence without disclosing anything.
83
+
84
+ Contract and regulatory material is wanted and citable: eduPersonAssurance, GÉANT and
85
+ eduGAIN terms, published wallet programme obligations.
@@ -0,0 +1,11 @@
1
+ SPDX-License-Identifier: EUPL-1.2
2
+
3
+ Licensed under the European Union Public Licence, Version 1.2 (the "EUPL").
4
+
5
+ You may obtain a copy of the licence at:
6
+
7
+ https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
11
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -0,0 +1,25 @@
1
+ PYTHON := .venv/bin/python
2
+ VENV := .venv
3
+
4
+ .DEFAULT_GOAL := help
5
+ .PHONY: help venv lint reformat test-local
6
+
7
+ help: ## Show available targets
8
+ @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
9
+ | awk -F':.*?## ' '{printf " %-14s %s\n", $$1, $$2}'
10
+
11
+ venv: ## Create .venv and install the package with its dev extra
12
+ test -d $(VENV) || uv venv
13
+ uv pip install -U -e ".[dev]"
14
+
15
+ lint: venv ## Run ruff checks and the type checker
16
+ $(PYTHON) -m ruff check src tests
17
+ $(PYTHON) -m ruff format --check src tests
18
+ $(PYTHON) -m ty check src
19
+
20
+ reformat: venv ## Autoformat and autofix
21
+ $(PYTHON) -m ruff format src tests
22
+ $(PYTHON) -m ruff check --fix src tests
23
+
24
+ test-local: venv ## Run the test suite
25
+ $(PYTHON) -m pytest -v
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: edutap.observability_settings
3
+ Version: 0.1.3
4
+ Summary: Error reporting, tracing and structured logging, wired the same way in every eduTAP service
5
+ Project-URL: Source, https://github.com/edutap-collective/edutap.observability_settings
6
+ Project-URL: Issues, https://github.com/edutap-collective/edutap.observability_settings/issues
7
+ Author: eduTAP
8
+ License-Expression: EUPL-1.2
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: System :: Monitoring
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.13
18
+ Requires-Dist: edutap-data-models<0.3,>=0.2.1
19
+ Requires-Dist: logfire>=4.40
20
+ Requires-Dist: pydantic-settings>=2.4
21
+ Requires-Dist: pydantic>=2.8
22
+ Requires-Dist: sentry-sdk>=2.67
23
+ Requires-Dist: structlog>=25
24
+ Provides-Extra: dev
25
+ Requires-Dist: pdbp; extra == 'dev'
26
+ Requires-Dist: pytest>=8.2; extra == 'dev'
27
+ Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
28
+ Requires-Dist: ty; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: myst-parser; extra == 'docs'
31
+ Requires-Dist: sphinx>=8; extra == 'docs'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # edutap.observability_settings
35
+
36
+ Error reporting, tracing and structured logging, wired the same way in every eduTAP
37
+ service — and one decision written down: what a service may say about a person.
38
+
39
+ ## Why this exists
40
+
41
+ **Because the options are the point, not the code.** Three lines would start Sentry.
42
+ Which options those three lines carry decides whether a bearer token or a person's
43
+ identifier leaves the process, and each of the options below was chosen against a
44
+ measurement rather than against a backend's recommendation. Separated from the
45
+ `sentry_sdk.init()` call that applies them, those measurements are worth nothing —
46
+ which is why this is a package and not a paragraph in a README.
47
+
48
+ **Because a person's identifier is not an opaque handle.** At a university a
49
+ `person_uid` resolves to a human being for far more people than hold directory
50
+ administration rights; at the LMU it is the LMU identifier with `@lmu.de` appended.
51
+ Pseudonymised it remains personal data. What a deployment decides here is not
52
+ *whether* it is personal data but *who may see it*.
53
+
54
+ ## Usage
55
+
56
+ ```python
57
+ from edutap.observability_settings import install_observability, person_label
58
+
59
+ install_observability(service_name="lmu_edutap_worker") # first thing in main()
60
+
61
+ log.warning("no view for person", person=person_label(uid, settings))
62
+ ```
63
+
64
+ Call it before the service resolves the settings it needs to run, so that a process
65
+ refusing to start is reported rather than silently absent. Nothing here can fail for
66
+ want of a value.
67
+
68
+ ## Configuration
69
+
70
+ One prefix for the whole estate, `EDUTAP_`. These fields are defined by an eduTAP
71
+ package, and another university deploying them should not have to learn an LMU name.
72
+
73
+ | Variable | Default | Meaning |
74
+ |---|---|---|
75
+ | `EDUTAP_ENVIRONMENT` | `production` | Labels every event and every span. Unset must not masquerade as development. |
76
+ | `EDUTAP_TELEMETRY_ENABLED` | `true` | The deliberate off switch for tracing, metrics and log export. |
77
+ | `EDUTAP_LOG_LEVEL` | `INFO` | A closed set; a misspelled level fails at startup. |
78
+ | `EDUTAP_SENTRY_DSN` | unset | Unset means no error tracker. One project per service. |
79
+ | `EDUTAP_PSEUDONYM_SALT` | unset | The HMAC key behind the person pseudonym. Without it there is no pseudonym at all. |
80
+ | `EDUTAP_PERSON_UID_MODE` | `pseudonym` | `pseudonym` · `plain` · `omit` |
81
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | **Not** an `EDUTAP_` field — see below. |
82
+
83
+ ### Why the endpoint is not one of ours
84
+
85
+ A Sentry DSN names a **project**, so it belongs to the service and every service gets
86
+ its own. An OTLP endpoint names a **receiver**, normally one per host or cluster, and
87
+ which service sent a span rides in the resource attributes rather than in the address.
88
+ Every OpenTelemetry SDK already reads `OTEL_EXPORTER_OTLP_ENDPOINT` by itself; a
89
+ field under `EDUTAP_` would be a second name for the same value.
90
+
91
+ ```{note}
92
+ A shared prefix means shared *names*, not shared *values*. `EDUTAP_SENTRY_DSN` differs
93
+ per service by being set in that service's own compose `environment:` block. Only
94
+ genuinely stack-wide values belong in the shared `.env`.
95
+ ```
96
+
97
+ ### The three modes for a person
98
+
99
+ | Mode | What travels | When it is right |
100
+ |---|---|---|
101
+ | `pseudonym` | a keyed, 12-character label | The default. Correlation survives — forty errors about one person still read as one person — identification does not. |
102
+ | `plain` | the `person_uid` itself | Where the error tracker is read by exactly the people who may read the directory anyway. A configured decision, not an accident. |
103
+ | `omit` | nothing | Where the tracker is read more widely than the directory. |
104
+
105
+ `pseudonym` without `EDUTAP_PSEUDONYM_SALT` yields **nothing**, never the raw value: a
106
+ deployment that asked for pseudonyms and forgot the key has to lose the datum rather
107
+ than publish it. An empty salt counts as no salt — compose writes `${VAR:-}`, which
108
+ sets a variable to the empty string, and an HMAC under an empty key is a plain digest
109
+ of a small, enumerable value space and reversible by anyone who can hash the directory.
110
+
111
+ ## What the three backends do
112
+
113
+ Sentry takes errors. An OTLP collector takes traces and metrics. structlog produces
114
+ the records that reach both, bridged by `logfire.StructlogProcessor`, so a log line
115
+ and the span it happened inside share a trace id without the caller doing anything.
116
+
117
+ Nothing travels two paths: Sentry's own tracing stays off (`traces_sample_rate=0`),
118
+ because the spans already go to the collector and Bugsink — the tracker this estate
119
+ runs — states that it does not support traces.
120
+
121
+ ```{important}
122
+ `send_to_logfire=False` is not a detail. The library defaults it to `True`, so leaving
123
+ it unset would ship spans to a hosted third party the first time a token happened to
124
+ be present.
125
+ ```
126
+
127
+ While no collector exists, the console stands in. Measured against logfire 4.40: with
128
+ `send_to_logfire=False` and no `OTEL_EXPORTER_OTLP_ENDPOINT`, no exporter is installed
129
+ at all, so an instrumented service would be indistinguishable from an uninstrumented
130
+ one — which is how instrumentation reaches production broken. Once the endpoint is
131
+ set, the console stands down.
132
+
133
+ ## Development
134
+
135
+ ```shell
136
+ make venv
137
+ make lint
138
+ make test-local
139
+ ```
140
+
141
+ `tox` runs the suite across every supported Python version.
142
+
143
+ ## Design records
144
+
145
+ Under [`docs/superpowers/specs/`](docs/superpowers/specs/).
@@ -0,0 +1,112 @@
1
+ # edutap.observability_settings
2
+
3
+ Error reporting, tracing and structured logging, wired the same way in every eduTAP
4
+ service — and one decision written down: what a service may say about a person.
5
+
6
+ ## Why this exists
7
+
8
+ **Because the options are the point, not the code.** Three lines would start Sentry.
9
+ Which options those three lines carry decides whether a bearer token or a person's
10
+ identifier leaves the process, and each of the options below was chosen against a
11
+ measurement rather than against a backend's recommendation. Separated from the
12
+ `sentry_sdk.init()` call that applies them, those measurements are worth nothing —
13
+ which is why this is a package and not a paragraph in a README.
14
+
15
+ **Because a person's identifier is not an opaque handle.** At a university a
16
+ `person_uid` resolves to a human being for far more people than hold directory
17
+ administration rights; at the LMU it is the LMU identifier with `@lmu.de` appended.
18
+ Pseudonymised it remains personal data. What a deployment decides here is not
19
+ *whether* it is personal data but *who may see it*.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from edutap.observability_settings import install_observability, person_label
25
+
26
+ install_observability(service_name="lmu_edutap_worker") # first thing in main()
27
+
28
+ log.warning("no view for person", person=person_label(uid, settings))
29
+ ```
30
+
31
+ Call it before the service resolves the settings it needs to run, so that a process
32
+ refusing to start is reported rather than silently absent. Nothing here can fail for
33
+ want of a value.
34
+
35
+ ## Configuration
36
+
37
+ One prefix for the whole estate, `EDUTAP_`. These fields are defined by an eduTAP
38
+ package, and another university deploying them should not have to learn an LMU name.
39
+
40
+ | Variable | Default | Meaning |
41
+ |---|---|---|
42
+ | `EDUTAP_ENVIRONMENT` | `production` | Labels every event and every span. Unset must not masquerade as development. |
43
+ | `EDUTAP_TELEMETRY_ENABLED` | `true` | The deliberate off switch for tracing, metrics and log export. |
44
+ | `EDUTAP_LOG_LEVEL` | `INFO` | A closed set; a misspelled level fails at startup. |
45
+ | `EDUTAP_SENTRY_DSN` | unset | Unset means no error tracker. One project per service. |
46
+ | `EDUTAP_PSEUDONYM_SALT` | unset | The HMAC key behind the person pseudonym. Without it there is no pseudonym at all. |
47
+ | `EDUTAP_PERSON_UID_MODE` | `pseudonym` | `pseudonym` · `plain` · `omit` |
48
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | **Not** an `EDUTAP_` field — see below. |
49
+
50
+ ### Why the endpoint is not one of ours
51
+
52
+ A Sentry DSN names a **project**, so it belongs to the service and every service gets
53
+ its own. An OTLP endpoint names a **receiver**, normally one per host or cluster, and
54
+ which service sent a span rides in the resource attributes rather than in the address.
55
+ Every OpenTelemetry SDK already reads `OTEL_EXPORTER_OTLP_ENDPOINT` by itself; a
56
+ field under `EDUTAP_` would be a second name for the same value.
57
+
58
+ ```{note}
59
+ A shared prefix means shared *names*, not shared *values*. `EDUTAP_SENTRY_DSN` differs
60
+ per service by being set in that service's own compose `environment:` block. Only
61
+ genuinely stack-wide values belong in the shared `.env`.
62
+ ```
63
+
64
+ ### The three modes for a person
65
+
66
+ | Mode | What travels | When it is right |
67
+ |---|---|---|
68
+ | `pseudonym` | a keyed, 12-character label | The default. Correlation survives — forty errors about one person still read as one person — identification does not. |
69
+ | `plain` | the `person_uid` itself | Where the error tracker is read by exactly the people who may read the directory anyway. A configured decision, not an accident. |
70
+ | `omit` | nothing | Where the tracker is read more widely than the directory. |
71
+
72
+ `pseudonym` without `EDUTAP_PSEUDONYM_SALT` yields **nothing**, never the raw value: a
73
+ deployment that asked for pseudonyms and forgot the key has to lose the datum rather
74
+ than publish it. An empty salt counts as no salt — compose writes `${VAR:-}`, which
75
+ sets a variable to the empty string, and an HMAC under an empty key is a plain digest
76
+ of a small, enumerable value space and reversible by anyone who can hash the directory.
77
+
78
+ ## What the three backends do
79
+
80
+ Sentry takes errors. An OTLP collector takes traces and metrics. structlog produces
81
+ the records that reach both, bridged by `logfire.StructlogProcessor`, so a log line
82
+ and the span it happened inside share a trace id without the caller doing anything.
83
+
84
+ Nothing travels two paths: Sentry's own tracing stays off (`traces_sample_rate=0`),
85
+ because the spans already go to the collector and Bugsink — the tracker this estate
86
+ runs — states that it does not support traces.
87
+
88
+ ```{important}
89
+ `send_to_logfire=False` is not a detail. The library defaults it to `True`, so leaving
90
+ it unset would ship spans to a hosted third party the first time a token happened to
91
+ be present.
92
+ ```
93
+
94
+ While no collector exists, the console stands in. Measured against logfire 4.40: with
95
+ `send_to_logfire=False` and no `OTEL_EXPORTER_OTLP_ENDPOINT`, no exporter is installed
96
+ at all, so an instrumented service would be indistinguishable from an uninstrumented
97
+ one — which is how instrumentation reaches production broken. Once the endpoint is
98
+ set, the console stands down.
99
+
100
+ ## Development
101
+
102
+ ```shell
103
+ make venv
104
+ make lint
105
+ make test-local
106
+ ```
107
+
108
+ `tox` runs the suite across every supported Python version.
109
+
110
+ ## Design records
111
+
112
+ Under [`docs/superpowers/specs/`](docs/superpowers/specs/).
@@ -0,0 +1,15 @@
1
+ # Design records (Claude/Superpowers documents)
2
+
3
+ * **Spec** (`specs/YYYY-MM-DD-<topic>-design.md`) — the design worked out in dialogue.
4
+ * **Plan** (`plans/YYYY-MM-DD-<topic>.md`) — the implementation derived from it.
5
+
6
+ | Date | Topic | Spec |
7
+ |---|---|---|
8
+ | 2026-08-10 | Why this package exists, the three backends, and what may be said about a person | [`specs/2026-08-10-observability-settings-design.md`](specs/2026-08-10-observability-settings-design.md) |
9
+
10
+ Records are snapshots of a decision at a point in time. They are not rewritten to
11
+ match a later state; a changed decision gets a new record.
12
+
13
+ The Sentry half of the design is inherited rather than invented here: the options and
14
+ the measurements behind them come from `edutap.data_provider`,
15
+ `docs/superpowers/specs/2026-08-04-observability-design.md`.