edutap.observability-settings 0.1.3__py3-none-any.whl
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.
- edutap/observability_settings/__init__.py +31 -0
- edutap/observability_settings/install.py +153 -0
- edutap/observability_settings/pseudonym.py +57 -0
- edutap/observability_settings/settings.py +72 -0
- edutap_observability_settings-0.1.3.dist-info/METADATA +145 -0
- edutap_observability_settings-0.1.3.dist-info/RECORD +8 -0
- edutap_observability_settings-0.1.3.dist-info/WHEEL +4 -0
- edutap_observability_settings-0.1.3.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""How an eduTAP service reports what it is doing, and what it may say about people.
|
|
2
|
+
|
|
3
|
+
Three backends with three jobs: Sentry takes errors, an OTLP collector takes traces
|
|
4
|
+
and metrics, structlog produces the records that reach both. This package holds the
|
|
5
|
+
settings for all three and the one call that applies them.
|
|
6
|
+
|
|
7
|
+
It exists because the options are the point, not the code. Which Sentry options are
|
|
8
|
+
on decides whether a bearer token or a person's identifier leaves the process, and
|
|
9
|
+
each of them was chosen against a measurement rather than against a backend's
|
|
10
|
+
recommendation. Separated from the ``sentry_sdk.init()`` call that applies them those
|
|
11
|
+
measurements are worth nothing, which is why the settings live here and not in
|
|
12
|
+
``edutap.data_models``.
|
|
13
|
+
|
|
14
|
+
What must never appear here is a dependency on any eduTAP service. This is installed
|
|
15
|
+
before a service resolves its own settings; it can know nothing about them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .install import install_observability, logfire_options, sentry_options
|
|
19
|
+
from .pseudonym import person_label, pseudonym
|
|
20
|
+
from .settings import OTLP_ENDPOINT_VARIABLE, ObservabilitySettings, PersonUidMode
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"OTLP_ENDPOINT_VARIABLE",
|
|
24
|
+
"ObservabilitySettings",
|
|
25
|
+
"PersonUidMode",
|
|
26
|
+
"install_observability",
|
|
27
|
+
"logfire_options",
|
|
28
|
+
"person_label",
|
|
29
|
+
"pseudonym",
|
|
30
|
+
"sentry_options",
|
|
31
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Wiring the three backends the same way in every eduTAP service.
|
|
2
|
+
|
|
3
|
+
Three systems with three jobs, and the split is deliberate rather than historical:
|
|
4
|
+
**Sentry** takes errors, **the OTLP collector** takes traces and metrics, and
|
|
5
|
+
**structlog** produces the records that go to both. Nothing travels two paths --
|
|
6
|
+
Sentry's own tracing stays off, because the spans already go to the collector and
|
|
7
|
+
Bugsink, the tracker this estate runs, states that it does not support traces.
|
|
8
|
+
|
|
9
|
+
The options are returned by pure functions before they are applied. That is what lets
|
|
10
|
+
a test assert the exact set rather than assert that *something* was configured, and it
|
|
11
|
+
is the same shape ``edutap.data_provider`` uses, from whose observability design
|
|
12
|
+
record the Sentry options and their measurements are taken.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import logfire
|
|
20
|
+
import sentry_sdk
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
from .settings import OTLP_ENDPOINT_VARIABLE, ObservabilitySettings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sentry_options(settings: ObservabilitySettings) -> dict[str, Any]:
|
|
27
|
+
"""Return the options that decide what leaves the process.
|
|
28
|
+
|
|
29
|
+
Each contradicts the backend's own default, and each was chosen against a
|
|
30
|
+
measurement recorded in the data provider's observability design record.
|
|
31
|
+
|
|
32
|
+
``include_local_variables=False`` -- with local variables on, a raw
|
|
33
|
+
``Authorization`` header sits in the ASGI scope, which is a local in most frames of
|
|
34
|
+
an ASGI stack, and the bearer token then appears dozens of times in an event whose
|
|
35
|
+
rendered ``authorization`` header says ``[Filtered]``. Sentry's scrubber matches
|
|
36
|
+
key names; it does not walk a list of byte tuples.
|
|
37
|
+
|
|
38
|
+
``max_request_body_size="never"`` -- for a service whose request body *is* the
|
|
39
|
+
identifying datum there is no partial version of this.
|
|
40
|
+
|
|
41
|
+
``max_breadcrumbs=0`` -- Sentry's ``LoggingIntegration`` is on by default and turns
|
|
42
|
+
every WARNING/ERROR record into a breadcrumb carrying the record's formatted
|
|
43
|
+
message verbatim and unscrubbed, on a path none of the other options constrains.
|
|
44
|
+
A hook dropping only ``type == "log"`` breadcrumbs was the narrower alternative and
|
|
45
|
+
was rejected: it would need re-auditing against every breadcrumb-producing
|
|
46
|
+
integration added in the future, whereas turning them off closes the surface once.
|
|
47
|
+
The cost is real -- an event carries no timeline of what happened earlier -- and it
|
|
48
|
+
is the same trade already made for local variables and the request body.
|
|
49
|
+
|
|
50
|
+
``Any`` rather than ``object`` in the return type, which is the one place this
|
|
51
|
+
package spends the escape hatch: the mapping is heterogeneous by nature and is
|
|
52
|
+
unpacked into a third-party signature, and ``object`` makes a type checker reject
|
|
53
|
+
every single key -- measured, 71 diagnostics for six options. Narrowing it to a
|
|
54
|
+
``TypedDict`` would restate sentry-sdk's signature here and go stale with its next
|
|
55
|
+
release.
|
|
56
|
+
"""
|
|
57
|
+
return {
|
|
58
|
+
"environment": settings.environment,
|
|
59
|
+
"traces_sample_rate": 0,
|
|
60
|
+
"send_default_pii": False,
|
|
61
|
+
"include_local_variables": False,
|
|
62
|
+
"max_request_body_size": "never",
|
|
63
|
+
"max_breadcrumbs": 0,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def logfire_options(
|
|
68
|
+
settings: ObservabilitySettings,
|
|
69
|
+
*,
|
|
70
|
+
service_name: str,
|
|
71
|
+
service_version: str | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Return how this service configures tracing, metrics and log export.
|
|
74
|
+
|
|
75
|
+
``send_to_logfire=False`` is not a detail. The library defaults it to ``True``, so
|
|
76
|
+
leaving it unset would ship spans to a hosted third party the first time a token
|
|
77
|
+
happened to be present. This estate exports to its own collector.
|
|
78
|
+
|
|
79
|
+
The console stands in while there is no collector. Measured against logfire 4.40:
|
|
80
|
+
with ``send_to_logfire=False`` and no ``OTEL_EXPORTER_OTLP_ENDPOINT``, no exporter
|
|
81
|
+
is installed at all, so an instrumented service would be indistinguishable from an
|
|
82
|
+
uninstrumented one -- which is how instrumentation reaches production broken. Once
|
|
83
|
+
the endpoint is set the console stands down, because printing every span into the
|
|
84
|
+
container log is a development aid and not a production one.
|
|
85
|
+
"""
|
|
86
|
+
exports_to_a_collector = bool(os.environ.get(OTLP_ENDPOINT_VARIABLE))
|
|
87
|
+
return {
|
|
88
|
+
"send_to_logfire": False,
|
|
89
|
+
"service_name": service_name,
|
|
90
|
+
"service_version": service_version,
|
|
91
|
+
"environment": settings.environment,
|
|
92
|
+
"console": False if exports_to_a_collector else logfire.ConsoleOptions(),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def install_observability(
|
|
97
|
+
settings: ObservabilitySettings | None = None,
|
|
98
|
+
*,
|
|
99
|
+
service_name: str,
|
|
100
|
+
service_version: str | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Install error reporting, tracing and structured logging for this process.
|
|
103
|
+
|
|
104
|
+
Call it first, before the service resolves the settings it needs to run, so that a
|
|
105
|
+
process refusing to start is reported rather than silently absent.
|
|
106
|
+
|
|
107
|
+
Both backends are opt-in by configuration and not by code: no DSN means no error
|
|
108
|
+
tracker, ``telemetry_enabled=False`` means no tracing. Structured logging is set up
|
|
109
|
+
either way -- a service without a collector still has to be readable.
|
|
110
|
+
"""
|
|
111
|
+
settings = settings or ObservabilitySettings()
|
|
112
|
+
|
|
113
|
+
if settings.telemetry_enabled:
|
|
114
|
+
logfire.configure(
|
|
115
|
+
**logfire_options(settings, service_name=service_name, service_version=service_version)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
_configure_structlog(settings)
|
|
119
|
+
|
|
120
|
+
if settings.sentry_dsn is not None:
|
|
121
|
+
sentry_sdk.init(dsn=settings.sentry_dsn.get_secret_value(), **sentry_options(settings))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _configure_structlog(settings: ObservabilitySettings) -> None:
|
|
125
|
+
"""Point structlog at the collector and render what is left as JSON.
|
|
126
|
+
|
|
127
|
+
``logfire.StructlogProcessor`` is the bridge: it turns each event into a log
|
|
128
|
+
record on the OTel side, so a log line and the span it happened inside share a
|
|
129
|
+
trace id without the caller doing anything. It sits in the chain rather than at
|
|
130
|
+
the end, because the record still has to be rendered for the container log.
|
|
131
|
+
|
|
132
|
+
JSON rather than the developer console renderer: these lines are read out of
|
|
133
|
+
``docker service logs`` and, later, out of the collector. A DLQ entry that cannot
|
|
134
|
+
be found by ``edutap-event-id`` is a DLQ entry nobody replays.
|
|
135
|
+
"""
|
|
136
|
+
processors: list[structlog.typing.Processor] = [
|
|
137
|
+
structlog.contextvars.merge_contextvars,
|
|
138
|
+
structlog.processors.add_log_level,
|
|
139
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
140
|
+
structlog.processors.StackInfoRenderer(),
|
|
141
|
+
structlog.processors.format_exc_info,
|
|
142
|
+
]
|
|
143
|
+
if settings.telemetry_enabled:
|
|
144
|
+
processors.append(logfire.StructlogProcessor())
|
|
145
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
146
|
+
|
|
147
|
+
structlog.configure(
|
|
148
|
+
processors=processors,
|
|
149
|
+
wrapper_class=structlog.make_filtering_bound_logger(
|
|
150
|
+
logging.getLevelNamesMapping()[settings.log_level]
|
|
151
|
+
),
|
|
152
|
+
cache_logger_on_first_use=True,
|
|
153
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Standing in for a person, or refusing to.
|
|
2
|
+
|
|
3
|
+
An error tracker is a machine that copies the state around a failure into a second
|
|
4
|
+
system. A ``person_uid`` at a university is not an opaque handle -- at the LMU it is
|
|
5
|
+
the LMU identifier with ``@lmu.de`` appended, and the circle able to turn that back
|
|
6
|
+
into a human being is far wider than the circle with directory administration rights.
|
|
7
|
+
Pseudonymised it is still personal data; the question a deployment answers here is not
|
|
8
|
+
*whether* but *who may see it*.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
|
|
14
|
+
from pydantic import SecretStr
|
|
15
|
+
|
|
16
|
+
from .settings import ObservabilitySettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pseudonym(person_uid: str, salt: SecretStr | None) -> str | None:
|
|
20
|
+
"""Return a keyed, truncated stand-in for a person, or nothing without a key.
|
|
21
|
+
|
|
22
|
+
Keyed rather than a plain digest: a ``person_uid`` comes from a directory, so the
|
|
23
|
+
value space is small and enumerable, and an unsalted hash would be reversible by
|
|
24
|
+
anyone able to read the error tracker -- simply by hashing the directory.
|
|
25
|
+
|
|
26
|
+
Truncated to 12 hex characters, 48 bits: wide enough that two people in one
|
|
27
|
+
installation colliding is not a practical concern, short enough that the result
|
|
28
|
+
reads as a label rather than as an identifier worth storing.
|
|
29
|
+
|
|
30
|
+
An *empty* salt counts as no salt. ``compose.yml`` writes ``${VAR:-}``, which sets
|
|
31
|
+
a variable to the empty string rather than leaving it unset, and an HMAC under an
|
|
32
|
+
empty key is a well-defined, entirely unkeyed digest -- precisely the
|
|
33
|
+
hash-the-directory construction the key exists to prevent, and indistinguishable
|
|
34
|
+
from a real pseudonym by eye.
|
|
35
|
+
"""
|
|
36
|
+
if salt is None or not salt.get_secret_value():
|
|
37
|
+
return None
|
|
38
|
+
digest = hmac.new(
|
|
39
|
+
salt.get_secret_value().encode("utf-8"),
|
|
40
|
+
person_uid.encode("utf-8"),
|
|
41
|
+
hashlib.sha256,
|
|
42
|
+
)
|
|
43
|
+
return digest.hexdigest()[:12]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def person_label(person_uid: str, settings: ObservabilitySettings) -> str | None:
|
|
47
|
+
"""Return what may stand for this person in what leaves the process.
|
|
48
|
+
|
|
49
|
+
``None`` means: attach nothing. Callers must treat that as an answer rather than
|
|
50
|
+
as a reason to fall back to the raw value -- a deployment that asked for
|
|
51
|
+
pseudonyms and forgot the key has to lose the datum, not publish it.
|
|
52
|
+
"""
|
|
53
|
+
if settings.person_uid_mode == "omit":
|
|
54
|
+
return None
|
|
55
|
+
if settings.person_uid_mode == "plain":
|
|
56
|
+
return person_uid
|
|
57
|
+
return pseudonym(person_uid, settings.pseudonym_salt)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Where a service reports to, and how much of a person may travel with it."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from edutap.data_models.settings import ServiceSettings
|
|
6
|
+
from pydantic import SecretStr
|
|
7
|
+
from pydantic_settings import SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
#: What may stand in for a ``person_uid`` in an error report or a span.
|
|
10
|
+
#:
|
|
11
|
+
#: ``pseudonym`` keeps correlation without identification, ``plain`` is the honest
|
|
12
|
+
#: choice where the tracker is read by exactly the people who may read the directory
|
|
13
|
+
#: anyway, and ``omit`` drops the datum entirely.
|
|
14
|
+
PersonUidMode = Literal["pseudonym", "plain", "omit"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ObservabilitySettings(ServiceSettings):
|
|
18
|
+
"""Everything the reporting side of a service needs, and nothing it can fail on.
|
|
19
|
+
|
|
20
|
+
Inherits :class:`~edutap.data_models.settings.ServiceSettings` rather than sitting
|
|
21
|
+
beside it: ``environment``, ``telemetry_enabled`` and ``log_level`` are read here,
|
|
22
|
+
and a service should not have to assemble two objects to start reporting.
|
|
23
|
+
|
|
24
|
+
**No field is required.** Observability is installed *before* a service resolves
|
|
25
|
+
the settings it needs to run, so that a process refusing to start is reported
|
|
26
|
+
rather than silently absent. That ordering only holds if reading this can never
|
|
27
|
+
fail for want of a value. A value that is present but not a legal one still
|
|
28
|
+
fails -- ignoring it would decide in silence what leaves the process.
|
|
29
|
+
|
|
30
|
+
The prefix is ``EDUTAP_`` and deliberately not per-service. These fields are
|
|
31
|
+
defined by an eduTAP package, another university deploying them should not have to
|
|
32
|
+
learn an LMU name, and the one field whose value genuinely differs per service --
|
|
33
|
+
the Sentry DSN, which identifies one project -- differs by being set in that
|
|
34
|
+
service's own environment block, not by carrying a different name.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
model_config = SettingsConfigDict(
|
|
38
|
+
env_prefix="EDUTAP_",
|
|
39
|
+
env_file=".env",
|
|
40
|
+
env_file_encoding="utf-8",
|
|
41
|
+
extra="ignore",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
#: A DSN identifies one project, so every service gets its own rather than sharing
|
|
45
|
+
#: one. It is a write-only ingest key and thus low-sensitivity -- a plain
|
|
46
|
+
#: environment variable is enough and a Docker secret would be effort without
|
|
47
|
+
#: gain -- but it is still a credential, hence ``SecretStr``: ``BaseSettings``
|
|
48
|
+
#: prints every plain field verbatim, and a settings object reaches a log line
|
|
49
|
+
#: sooner or later. Unset means the integration stays off.
|
|
50
|
+
sentry_dsn: SecretStr | None = None
|
|
51
|
+
|
|
52
|
+
#: The HMAC key behind :func:`~edutap.observability_settings.pseudonym.pseudonym`.
|
|
53
|
+
#: Without it there is no pseudonym at all, rather than one computed from an empty
|
|
54
|
+
#: key -- that would be a plain digest of a small, enumerable value space, and
|
|
55
|
+
#: reversible by anyone able to hash the directory.
|
|
56
|
+
pseudonym_salt: SecretStr | None = None
|
|
57
|
+
|
|
58
|
+
#: Whether a person may be named in what leaves the process. The default is the
|
|
59
|
+
#: careful one, so that a deployment which has not thought about it does not
|
|
60
|
+
#: publish identifiers by omission.
|
|
61
|
+
person_uid_mode: PersonUidMode = "pseudonym"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Where the OTLP endpoint comes from -- the OpenTelemetry specification's own
|
|
65
|
+
#: variable, not a field of this class.
|
|
66
|
+
#:
|
|
67
|
+
#: An endpoint names a *receiver*, normally one per host or cluster, and which service
|
|
68
|
+
#: sent a span is carried in the resource attributes rather than in the address. A
|
|
69
|
+
#: field under ``EDUTAP_`` would be a second name for a value every OTel SDK already
|
|
70
|
+
#: reads by itself. This is the asymmetry with the Sentry DSN, which names a project
|
|
71
|
+
#: and therefore does belong to the service.
|
|
72
|
+
OTLP_ENDPOINT_VARIABLE = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
@@ -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,8 @@
|
|
|
1
|
+
edutap/observability_settings/__init__.py,sha256=f4m0YUPEnwo7yP2zN4YgzYr3Q8fPQTwButFmO-VWPEM,1327
|
|
2
|
+
edutap/observability_settings/install.py,sha256=2dExNLEuQ5HW6kD1TKlnuaEgdG-rz_4ruvhmbQ_vDfY,6926
|
|
3
|
+
edutap/observability_settings/pseudonym.py,sha256=rgJcQbOcbdC-bDqlOm3iYEoT9nQA0w7QWaF7BWq-jok,2458
|
|
4
|
+
edutap/observability_settings/settings.py,sha256=TEfAQKoWmiVgfsUCZNn89JRtoOnFRkiwVd3AO6RWzn8,3653
|
|
5
|
+
edutap_observability_settings-0.1.3.dist-info/METADATA,sha256=u1BebmYrIcOakN0deuhb1rz_HqwGzHOpOm1zvPh2oDo,6663
|
|
6
|
+
edutap_observability_settings-0.1.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
edutap_observability_settings-0.1.3.dist-info/licenses/LICENSE,sha256=2UI9yv7y2X_WmlpE41IbjrcqqIrC3cAWMh40FWrSNLo,428
|
|
8
|
+
edutap_observability_settings-0.1.3.dist-info/RECORD,,
|
|
@@ -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.
|