Settings¶
servicewright reads a small, fixed set of things off a settings object that you own and
construct. The kernel never loads a .env, never reads os.environ, and never defines a class
you have to inherit from: it checks shape, not ancestry. The settings extra ships that shape as
ready-made pydantic-settings models, so you do not have to transcribe it.
The protocol¶
class BaseServiceSettingsProtocol(Protocol):
logging: LoggingSettingsProtocol | None
metrics: MetricsSettingsProtocol | None
tracing: TracingSettingsProtocol | None
error_tracking: ErrorTrackingSettingsProtocol | None
def get_app_version(self) -> str: ...
Four optional sections and a version. That is all the kernel asks for.
Sections are named by concern, never by vendor. There is no sentry section — there is an
error_tracking section, and ObsConfig decides that Sentry is the backend reading it. Swapping
Sentry for something else does not touch your settings class.
The sections¶
Each table lists the default the built-in backend falls back to when the field is missing from your section. The shipped models carry the same values, so a model of your own that uses them behaves exactly like the shipped one.
logging¶
| Field | Type | Default | Meaning |
|---|---|---|---|
level |
str |
"INFO" |
Root log level: DEBUG, INFO, ... |
use_json |
bool |
True |
JSON lines vs human-readable console output |
metrics¶
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool |
False |
Start a standalone exposition server |
host |
str |
"0.0.0.0" |
Bind host for that server |
port |
int |
9090 |
Bind port for that server |
allow_ephemeral_port |
bool |
False |
Accept port=0; validated by the shipped model, not read by the backend |
prefix |
str \| None |
None |
Metric name prefix, for backends that use one |
host and port are read only when enabled is true and have no backend fallback; their
defaults are the shipped model's.
Note
The built-in Prometheus backend does not apply prefix itself. Metric names belong to the
recorder that owns them, so prefixes are set there — for example
GrpcEntrypoint(metrics_prefix="myapp"). The field exists for backends that prefix
globally.
tracing¶
| Field | Type | Default | Meaning |
|---|---|---|---|
service_name |
str |
"" |
Resource service name; empty falls back to AppSpec.service_name |
collector_url |
str \| None |
None |
OTLP endpoint; None means no exporter |
sample_ratio |
float |
1.0 |
Ratio for the parent-based sampler |
insecure |
bool |
True |
Plaintext OTLP connection |
enable_console_exporter |
bool |
False |
Also print spans to stdout |
excluded_urls |
str \| None |
None |
Comma-separated paths to skip |
error_tracking¶
| Field | Type | Default | Meaning |
|---|---|---|---|
dsn |
str \| None |
None |
Reporting endpoint; empty means the concern is off |
environment |
str |
"" |
Environment tag; empty falls back to settings.environment |
traces_sample_rate |
float |
0.0 |
Performance-trace sampling |
profiles_sample_rate |
float |
0.0 |
Profiling sampling |
debug |
bool |
False |
SDK debug mode |
Narrowing a section¶
Sections may declare types narrower than the tables above: a Literal level, a bounded
sample_ratio, a port newtype. Section members are read-only properties and nothing in
servicewright writes to them, so a narrowed model still satisfies the protocol. LogLevelStr is
exported for the log level:
from servicewright.core.observability import LogLevelStr
class LoggingSettings(BaseModel):
level: LogLevelStr = "INFO"
use_json: bool = True
Optional extras¶
settings.environment— if present, it is used as the environment for observability setup. Otherwiseerror_tracking.environmentis used.- Backends may read additional fields off their own section via
getattr. The protocols declare the minimum, not the maximum, so a custom backend can carry its own configuration in the same section.
None disables a concern¶
@dataclass(frozen=True)
class Settings:
logging: LoggingSettings | None = LoggingSettings()
metrics: None = None # off
tracing: None = None # off
error_tracking: None = None # off
def get_app_version(self) -> str:
return "1.0.0"
A concern that is None gets a null-object sink: emitters can call it freely and nothing happens.
Your code never has to check whether metrics are configured.
Error tracking is special: the section may exist while dsn is empty, which also counts as off.
Shipped models¶
Install the settings extra and the four sections plus the composite come as pydantic models,
carrying the defaults above:
from pydantic_settings import SettingsConfigDict
from servicewright.adapters.settings import BaseServiceSettings
class Settings(BaseServiceSettings):
model_config = SettingsConfigDict(env_file=".env")
app_version: str = "1.4.0"
database_dsn: str
BaseServiceSettings is a pydantic_settings.BaseSettings with env_nested_delimiter="__", the
four sections, environment (default "local"), app_version (default "0.0.0") and a
get_app_version() returning it. Your model_config is merged with the base one, so an
env_file or an env_prefix of yours keeps the __ nesting.
What is on by default¶
| Section | Default | Why |
|---|---|---|
logging |
LoggingSettings() — INFO, JSON |
you want logs |
metrics |
MetricsSettings() — sink on, exposition server off |
inert until something records |
error_tracking |
ErrorTrackingSettings() — no dsn, so off |
switches on from ERROR_TRACKING__DSN alone |
tracing |
None |
a present section installs a tracer provider; TRACING__COLLECTOR_URL=... creates it |
A section typed TracingSettings | None is built from its nested variables the moment one is
set, so TRACING__COLLECTOR_URL=otel:4317 is all it takes to switch tracing on.
A present section still activates its backend at bootstrap, which needs that backend's extra
installed — observability for structlog and otel, metrics for prometheus. Otherwise set
the section to None or select another backend in ObsConfig. The settings
extra itself pulls in nothing but pydantic-settings.
Validation instead of silent fallbacks¶
The models validate what the backends assume: the log level must be one of LogLevelStr
(case-insensitively, so LOGGING__LEVEL=debug is fine while warn is a load-time error rather
than a silent INFO), the sample ratios are bounded to [0, 1], the metrics port to a port.
MetricsSettings also rejects port=0 unless allow_ephemeral_port=True. 0 binds an
ephemeral port, which is what a test wants and, in a pod, what a scraper pointed at a fixed port
never finds; the sink has no bound-port readback, so nothing in the process knows either, and 0
only ever arrives from configuration (METRICS__PORT, one keystroke from 9090). Say it in code
for a service that means it, or set METRICS__ALLOW_EPHEMERAL_PORT=true next to METRICS__PORT=0
when the port comes from the environment: a section is built afresh from its variables, so a
default instance on the settings class is replaced, not merged, once one METRICS__* variable is
set.
Disabling and narrowing¶
Exactly as with a hand-written class: a section annotated None is off, a section model can be
subclassed to narrow or extend it, and a default can be replaced:
from typing import Literal
from servicewright.adapters.settings import BaseServiceSettings, LoggingSettings, TracingSettings
class StrictLogging(LoggingSettings):
level: Literal["INFO", "WARNING", "ERROR"] = "INFO"
class Settings(BaseServiceSettings):
logging: StrictLogging = StrictLogging()
tracing: TracingSettings = TracingSettings(sample_ratio=0.1) # on, sampling 10 %
metrics: None = None # off
Writing your own¶
Anything with the right attribute names still works — a frozen dataclass, a plain class, a
pydantic-settings class of your own; Your first service
shows the smallest one. The kernel never imports pydantic: the models live in
servicewright.adapters.settings, behind their extra, like every other binding.
What is not in settings¶
Server configuration is not. HttpConfig, GrpcConfig and LitestarConfig are passed to their
entrypoint at construction:
This is deliberate. AppSpec stays transport-neutral, which is what lets the same spec run an
API in one process and a worker in another. It also means two HTTP entrypoints in one process can
have different ports without inventing a settings namespace for each.
Of course, nothing stops you feeding your own settings into that config — you do it explicitly, where you can see it — and for both transports the environment-facing side of the config ships next to it, so the feeding is one call rather than a transcription.
For HTTP it is the FastAPI adapter's own
HttpServerSettings: one pydantic model per config
dataclass, same fields and defaults, to_config() on each. Nest it in your settings class under
the name you choose:
from servicewright.adapters.fastapi import FastApiEntrypoint, HttpServerSettings
class Settings(BaseServiceSettings):
server: HttpServerSettings = HttpServerSettings()
FastApiEntrypoint(
config=settings.server.to_config(version=settings.app_version),
middlewares=settings.server.middlewares.to_config(),
)
For gRPC it is grpc-server-kit's BaseGrpcServerSettings, which carries the same field set as
GrpcConfig, and GrpcConfig.from_settings reads all of it:
GrpcEntrypoint(config=GrpcConfig.from_settings(settings.grpc, health_service_names=("my.pkg.Orders",)))
BaseServiceSettings itself carries neither section. It is transport-neutral, like AppSpec: a
gRPC-only service never grows an HTTP section it does not read, and two HTTP entrypoints in one
process are two sections under two names.
Next¶
- Observability — how
ObsConfigmaps backends onto these sections.