stratify 1.0.0

Layered configuration and structured logging for Rust services: pluggable sources, priority merging, typed access, and a tracing facade
Documentation

stratify

crates.io docs.rs CI MIT licensed

Layered configuration and structured logging for Rust services.

Two halves behind one crate. stratify::config stacks configuration from files, environment variables and Azure App Configuration, merges by declared precedence, and reads the result as typed values. stratify::logging — behind the logging feature, so a config-only build compiles none of it — is a non-blocking tracing facade with console, JSON, file and syslog sinks. stratify::init stands both up in one call.

use stratify::config::Builder;

let store = Builder::default()
    .json("config/base.json", 100)
    .yaml("config/override.yaml", 50)
    .env("APP_", "__", 10)
    .build()
    .await?;

let host: String = store.get_str("database.host").unwrap();
let port: u64 = store.get_u64("database.port").unwrap();

Precedence

Lower priority number wins. A source at priority 10 overrides one at 100.

That is the opposite of "higher number is more important", and it is deliberate: it lets you add a more specific source later without renumbering the ones already there. Think of it as a ranking, not a weight.

Merging is deep. Nested objects combine key by key rather than the higher-precedence source replacing the whole subtree.

Sources

Source Builder method Notes
JSON .json(path, priority)
YAML .yaml(path, priority)
TOML .toml(path, priority)
Env .env(prefix, separator, priority) APP_DB__HOST{"db": {"host": …}}
Env (named) .env_keys(["RUST_LOG", …], separator, priority) for variables with no shared prefix
DotEnv .dotenv(path, prefix, separator, priority) loads the file, then reads matching vars
Azure App Configuration .azure(endpoint, credential, priority) requires the azure feature

Implement [Source] for anything else — a database, a secret store, an HTTP endpoint.

Azure App Configuration

[dependencies]
stratify = { version = "1", features = ["azure"] }
azure_identity = "1"
use std::sync::Arc;
use azure_identity::ManagedIdentityCredential;
use stratify::config::Builder;

let credential = Arc::new(ManagedIdentityCredential::new(None)?);

let store = Builder::default()
    .json("config/base.json", 100)
    .azure("https://my-store.azconfig.io", credential, 10)
    .build()
    .await?;

The credential is yours to choose rather than something this crate decides. Use ManagedIdentityCredential in Azure and DeveloperToolsCredential on a workstation, and no secret has to be distributed either way. Any TokenCredential works, which also means the source can be tested against a fake.

Keys follow the .NET convention: Database:Host becomes {"database": {"host": …}}.

A store can hold Key Vault references instead of values — the common enterprise setup. with_key_vault_resolution() resolves them into the secrets they point at, reusing the same credential (the identity needs Key Vault Secrets User on each referenced vault). It is off by default, and encountering a reference with it off is an error naming the key, never a JSON envelope handed back as a value. Filter to one label with AzureAppConfigSource::with_label, which you will usually want — without it, every label in the store is fetched and a key present under several resolves unpredictably.

Typed access

#[derive(serde::Deserialize)]
struct Database {
    host: String,
    port: u16,
}

let db: Database = store.get("database")?;

Refresh

store.refresh().await? re-reads every source and re-merges, without recreating the store or restarting the process. The cache is left untouched if any source fails, so a transient outage does not blank your configuration.

Reads (get_str, get, …) are synchronous and lock-free on the happy path; only loading and refreshing are async.

Logging

[dependencies]
stratify = { version = "1", features = ["logging"] }
use stratify::logging::{self, ConsoleConfig, FileConfig};

let handle = logging::builder()
    .console(ConsoleConfig::default())
    .file(FileConfig::new("/var/log/myapp"))
    .console_filter("warn")           // per-sink filters
    .reloadable()                     // swap the global filter at runtime
    .init()?;

tracing::info!(request_id = "req-42", "server started");
handle.flush();

Every sink is non-blocking: writers flush on background threads, and the handle reports queue depth and dropped lines so backpressure is visible before it is fatal. Sinks: console (stderr or stdout), JSON, rotating file (daily, hourly, or by size, with retention and optional gzip via the compression feature), and syslog. Custom line formatters, field redaction and panic capture are built in, and appinsights adds Azure Application Insights export with trace correlation.

Logging can also be described rather than coded, in the same store as the rest of your configuration:

[logging]
level = "info"
redact = ["password", "authorization"]
capture_panics = true

[logging.file]
directory = "/var/log/myapp"
rotation = "daily"

[logging.filters]
app_insights = "info"        # the sink that costs money per event

[logging.app_insights]
service_name = "my-service"
sample_rate = 0.25           # fraction of traces exported, kept-or-dropped whole
# The connection string is a secret: the block names the key it is found
# under (default: applicationinsights_connection_string), and the value
# arrives through the store — environment, .env, or a vault-backed source.
use stratify::logging::settings::Settings;

let builder = Settings::from_store(&store, "logging")?;

One call to start a service

init reads configuration (config.toml < environment < .env), builds logging from its [logging] block, installs the subscriber, and hands back both halves:

let boot = stratify::init("my-service").await?;
let db_host = boot.config.get_str("database.host");
tracing::info!("up");
boot.logging.flush();

The first record the subscriber carries names the sources that resolved, so a wrong precedence stack is visible instead of silent. With the appinsights feature, a connection string reachable in the store (conventionally APPLICATIONINSIGHTS_CONNECTION_STRING, injected by the platform) turns the exporter on; its absence is a choice, not an error.

Feature flags

Feature Default Effect
azure no Azure App Configuration source; pulls azure_core and reqwest
logging no stratify::logging and stratify::init; pulls tracing-subscriber, tracing-appender, time
compression no gzip retired log files (implies logging)
appinsights no Azure Application Insights export with trace correlation (implies logging)

A config-only build stays a config library: CI fails if the default dependency tree ever contains tracing-subscriber, tracing-appender or any opentelemetry crate.

Versioning

1.0 moved the config API from the crate root into stratify::config (ConfigBuilderconfig::Builder, and so on), absorbed the logging half, and declared the API stable: from here a breaking change is a major version. 0.3 made Source::load async so that network-backed sources do not have to block a runtime thread.

Examples and design notes

Runnable examples live in examples/ and are compiled and run by CI, so they cannot drift from the API:

cargo run --example basic
cargo run --example precedence

docs/design.md covers why the priority ordering is inverted, why merging is deep, why Source::load is async, and why the Azure credential is supplied by the caller.

Contributing

See CONTRIBUTING.md. Pull requests are welcome; the review focus and the local commands CI mirrors are both documented there.

Security issues go through private vulnerability reporting rather than a public issue.

Supply chain

Actions are pinned to commit SHAs, cargo-deny gates advisories, licences, wildcard versions and dependency sources, git and unknown-registry dependencies are denied, and the crate is #![forbid(unsafe_code)]. See SECURITY.md.

A consumer takes 33 crates by default, or 145 with azure enabled — which is why the Azure source is behind a feature flag rather than always on.

License

MIT — see LICENSE.