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
27 changes: 22 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
# sentry-options

Sentry's internal option and feature-flag platform: schema-defined options, git-managed values, read at runtime — validated, hot-reloadable, and backed by Kubernetes ConfigMaps.
Sentry's internal option and feature-flag platform: schema-defined options, git-managed values, read at runtime. Values are validated, hot-reloadable, and backed by Kubernetes ConfigMaps. This repo holds the Rust and Python clients, the shared validation crate, `sentry-options-cli`, the `sentry-options-gen-stubs` pre-commit hook, and the reusable `validate-schema` workflow.

## Commands

- Rust format check: `cargo fmt --all -- --check`
- Rust lint: `cargo clippy --workspace --all-features --tests -- -D clippy::all`
- Rust tests: `SENTRY_OPTIONS_DIR=$PWD/sentry-options cargo test` (`.envrc` sets the variable)
- Python client tests, from `clients/python`: `uv sync --extra dev && uv run maturin develop && uv run pytest tests`
- Stub generator tests, from the repo root: `uv sync && uv run pytest`
- Pre-commit hooks: `pre-commit run --files <files>`

## Invariants

- Build the Python bindings (`clients/python`) with maturin, never `cargo build`. They are left out of the workspace `default-members` for this reason.
- Schema changes must follow the evolution rules in [docs/architecture.md](./docs/architecture.md#schema-evolution--validation). The `validate-schema` workflow enforces them.
- `CHANGELOG.md` is generated by craft at release (`changelogPolicy: auto` in `.craft.yml`). `scripts/bump-version.sh` sets the versions.

## Documentation

- [README](./README.md) — what it is and a code quickstart
- [docs/setup.md](./docs/setup.md) — integrating sentry-options into a new repo
- [docs/options.md](./docs/options.md) — adding/deleting options, setting values, testing
- [docs/architecture.md](./docs/architecture.md) — how it works: schema format, runtime model, feature-flag evaluation
- [README](./README.md): what it is and a code quickstart
- [docs/setup.md](./docs/setup.md): integrating sentry-options into a new repo
- [docs/options.md](./docs/options.md): adding and deleting options, setting values, testing
- [docs/architecture.md](./docs/architecture.md): schema format, runtime model, feature-flag evaluation, CLI output, the injector
- [examples/README.md](./examples/README.md): deploying the example image to a cluster
- [examples/minikube_propagation/README.md](./examples/minikube_propagation/README.md): measuring ConfigMap propagation latency locally
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# sentry-options

Sentry's internal runtime option and feature-flag platform. Options are defined in your repo and values are set in `sentry-options-automator`. Multiple layers of validation, hot-reloaded, and minimal infrastructure. Awesome.
Sentry's internal runtime option and feature-flag platform. Options are defined in your repo and values are set in `sentry-options-automator`. Values are validated at several layers, hot-reloaded, and need minimal infrastructure.

`sentry-options` was built to replace our legacy database-backed options with a straightforward config file (in the form of a mounted Kubernetes ConfigMap). This lowers infrastructure complexity, makes the code simpler to understand, and dramatically lowers upper percentile (i.e. P90) latency. `sentry-options` is also Rust native, supporting both Rust and Python client libraries out of the box.

## Documentation Reference
## Documentation

| Doc | What it covers |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| [Setup](./docs/setup.md) | Add `sentry-options` to a new repo |
| [Working with options](./docs/options.md) | Adding/deleting options, using options, setting option values, testing |
| [Architecture](./docs/architecture.md) | Technical reference: schema format, values & targets, runtime model, feature-flag evaluation |

## Quick Reference
## Quick reference

> See [Working with options](./docs/options.md) for a more detailed guide
To add sentry-options to a service, follow [Setup](./docs/setup.md). Then read options in code:

### Python

Expand Down Expand Up @@ -45,3 +45,7 @@ fn main() -> anyhow::Result<()> {
Ok(())
}
```

## Help and license

Report bugs and ask questions in [GitHub issues](https://github.com/getsentry/sentry-options/issues). sentry-options is licensed under [Apache-2.0](./LICENSE).
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Options must be initialized once at startup, guarded by a global `OnceLock`. Rus
| `.with_callback(cb)` | Register a reload callback |
| `.with_refresh_threshold(t)` | Override the refresh-on-read staleness threshold (default 5 s); `None` disables refresh-on-read |

Call `.build()` instead of `.init()` to get a standalone `Options` instance without setting the global store. The free functions `options()` and `features()` read only the global store.

`init()` is a shorthand for `Options::builder().init()`; the standalone `init_with_schemas` / `init_with_propagation_callback` functions are deprecated in favor of the builder. Python: `init(on_propagation=None, refresh_threshold=5.0, additional_schemas=None)`, where `additional_schemas` is a `{ns: json}` mapping equivalent to `.with_additional_schemas()`.

The `init()` shorthand and Python's `init()` are idempotent — calling again is a no-op. The builder's `.init()` instead returns `OptionsError::AlreadyInitialized` when options are already initialized, so re-initializing with different settings is a loud error rather than a silent no-op.
Expand All @@ -216,7 +218,7 @@ End to end, a value change propagates as: ConfigMap update → ~1–2 min kubele
Every refresh — lazy or manual — resets the staleness timer. Two consequences:

- **Preempting hot-path IO**: driving `refresh()` from your own timer more often than the threshold guarantees that reads never stat or parse files inline.
- **Manual-only mode**: passing `None` as the refresh threshold disables refresh-on-read entirely; values then only change when `refresh()` is called. Combined with the reload callback, this supports eagerly re-deserializing options into a typed struct off the hot path whenever `refresh()` reports a change.
- **Manual-only mode**: passing `None` as the refresh threshold disables refresh-on-read entirely; values then only change when `refresh()` is called. In Rust, `refresh()` is a method on an `Options` instance from `.build()`. The global store from `.init()` has no public `refresh()`. Combined with the reload callback, this supports eagerly re-deserializing options into a typed struct off the hot path whenever `refresh()` reports a change.

### Propagation metric

Expand Down
15 changes: 8 additions & 7 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The first half of this document covers runtime options. The second half covers f
- [Setting an option value locally](#setting-an-option-value-locally)
- [Adding an option](#adding-an-option)
- [Setting an option value](#setting-an-option-value)
- [Diffing effective values](#diffing-effective-values)
- [Deleting an option](#deleting-an-option)
- [Reading a feature flag](#reading-a-feature-flag)
- [Definitions](#definitions)
Expand Down Expand Up @@ -117,10 +118,10 @@ use sentry_options::{options};
// call init() somewhere early
#[test]
fn test_timeout() {
assert_eq!(options("seer").get("autofix.enabled").unwrap(), false);
assert_eq!(options("seer").unwrap().get("autofix.enabled").unwrap(), false);
// will be `true` for the lifetime of this guard
let _guard = override_options(&[("seer", "autofix.enabled", true.into())]).unwrap();
assert_eq!(options("seer").get("autofix.enabled").unwrap(), true);
assert_eq!(options("seer").unwrap().get("autofix.enabled").unwrap(), true);
}
```

Expand All @@ -129,9 +130,9 @@ Just like in Python, use `get_forced()` to bypass the 5s cache threshold and see
// import, create a values file, init(), etc...
fs::write("sentry-options/values/seer/values.json", br#"{"options": {"autofix.enabled": false}}"#)?;
// Cached get() still returns the old value within the 5s threshold.
assert_eq!(options("seer").get("autofix.enabled").unwrap(), true);
assert_eq!(options("seer")?.get("autofix.enabled")?, true);
// get_forced() bypasses the cache and returns the updated value immediately.
assert_eq!(options("seer").get_forced("autofix.enabled").unwrap(), false);
assert_eq!(options("seer")?.get_forced("autofix.enabled")?, false);
```

## Setting an option value locally
Expand Down Expand Up @@ -230,7 +231,7 @@ Dicts can have arbitrary keys that match the value type, so be careful when doin
{"anything": "x", "goes": "y"} // arbitrary keys, all string values
```

Adding an option with the Python library installed should trigger the ([precommit hook](./setup.md#2-add-workflows)).
If you installed the [`sentry-options-gen-stubs` pre-commit hook](./setup.md#2-add-workflows), it regenerates the Python type stubs when you change the schema.

## Setting an option value

Expand Down Expand Up @@ -501,9 +502,9 @@ options:
Similar to options, deleting a feature flag requires removing 3 components:

1. The usage in code (the `features.has(...)` callsite)
2. The definition (`manager.add` in sentry's `temporary.py` or `permanent.py`, or getsentry's `features.py`)
2. The definition: the `feature.` entry in `sentry-options/schemas/{namespace}/schema.json`. For sentry and getsentry, it is the `manager.add` call in sentry's `temporary.py` or `permanent.py`, or getsentry's `features.py`.
3. The value (in `sentry-options-automator`)

The above ordering is recommended to prevent any regressions in behavior. **1.** and **2.** can be done simultaneously in the same PR. Once the definition is removed the value is no longer read, but nothing flags a leftover `flagpole.yaml` block, so please remove the value yourself.

If you remove the value (**3.**) first, `features.has()` falls back to the `default=` in `manager.add`, usually `False`, so the flag turns off for everyone. If you remove the definition (**2.**) while checks remain, `features.has()` returns `False` and reports the error to Sentry.
If you remove the value (**3.**) first, the flag turns off for everyone. The sentry-options client returns `False` for a flag with no value, and sentry and getsentry fall back to the `default=` in `manager.add`, usually `False`. If you remove the definition (**2.**) while checks remain, `has()` returns `False`. sentry and getsentry also report the error to Sentry.
14 changes: 7 additions & 7 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:

validate:
needs: cli-version
uses: getsentry/sentry-options/.github/workflows/validate-schema.yml@0b115be89b102d76beff8106bd4054365954282e
uses: getsentry/sentry-options/.github/workflows/validate-schema.yml@1a5e3a280d665ceae23d3c98bc3cd56e6d94ab0f # 1.2.17
secrets:
SENTRY_READONLYBOT_PRIVATE_KEY: ${{ secrets.SENTRY_READONLYBOT_PRIVATE_KEY }}
with:
Expand All @@ -90,7 +90,7 @@ In `.pre-commit-config.yaml`
```yaml
# Generates sentry-options type stubs
- repo: https://github.com/getsentry/sentry-options
rev: 0b115be89b102d76beff8106bd4054365954282e
rev: 1a5e3a280d665ceae23d3c98bc3cd56e6d94ab0f # 1.2.17
hooks:
- id: sentry-options-gen-stubs
```
Expand All @@ -102,14 +102,14 @@ Again, check the repo for the latest SHAs (pinning actions at SHAs is better pra
In Python, this would be in `pyproject.toml` or similar:

```toml
dependencies = ["sentry-options>=1.1.1"]
dependencies = ["sentry-options>=1.2.17"]
```

In Rust, this would be in `Cargo.toml`:

```toml
[dependencies]
sentry-options = "1.1.1"
sentry-options = "1.2.17"
```

Check the [releases page](https://github.com/getsentry/sentry-options/releases) for the latest version.
Expand All @@ -128,7 +128,7 @@ init() # optional: init(on_propagation=Callback, refresh_threshold=5.0)

`on_propagation` is a callback fired whenever values reload, useful for collecting metrics. `refresh_threshold` sets the staleness window for refresh-on-read (default `5.0` seconds).

**Important:** Passing `None` into `refresh_threshold` will completely disable automatic refreshes and must be triggered manually via `sentry_options.refresh`.
**Important:** Passing `None` to `refresh_threshold` disables automatic refreshes. Values then change only when you call `sentry_options.refresh()`.

#### Rust

Expand All @@ -152,7 +152,7 @@ Options::builder()
.init()?;
```

**Important:** Passing `None` into `refresh_threshold` will completely disable automatic refreshes and must be triggered manually via `sentry_options.refresh`.
**Important:** Passing `None` to `with_refresh_threshold` disables automatic refreshes. Values then change only when you call `refresh()` on an `Options` instance, which you get from `Options::builder().build()` instead of `.init()`. The global store that `.init()` creates has no public `refresh()`, so `.with_refresh_threshold(None).init()` never reloads values.

`.init()` returns `OptionsError::AlreadyInitialized` if options are already initialized; ignore it with `.ok()` if that's expected. The standalone `init_with_schemas()` and `init_with_propagation_callback()` functions are **deprecated** in favor of the builder.

Expand Down Expand Up @@ -186,7 +186,7 @@ For validation to succeed in `sentry-options-automator` it must first know your
"repos": {
"seer": {
"url": "https://github.com/getsentry/seer",
"path": "sentry-options/"
"path": "sentry-options/schemas"
}
}
}
Expand Down
28 changes: 7 additions & 21 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The deployment model:

- **Schemas** are baked into the Docker image at build time
- **Values** are mounted via Kubernetes ConfigMap at runtime
- The client watches for ConfigMap changes and hot-reloads values without pod restart
- The client re-reads the mounted values on read and picks up ConfigMap changes without a pod restart

## Prerequisites

Expand All @@ -30,14 +30,6 @@ docker tag sentry-options-example \
docker push REGION-docker.pkg.dev/PROJECT_ID/REPO/sentry-options-test:latest
```

Example for sandbox `sbx-hdeng-1`:

```bash
docker tag sentry-options-example \
us-west1-docker.pkg.dev/eng-dev-sbx--hdeng-2/hdeng-images/sentry-options-test:latest
docker push us-west1-docker.pkg.dev/eng-dev-sbx--hdeng-2/hdeng-images/sentry-options-test:latest
```

## 2. Generate and Apply ConfigMap

```bash
Expand Down Expand Up @@ -67,13 +59,7 @@ kubectl apply -f configmap.yaml

## 3. Deploy Example Pod

An example deployment exists in `terraform-sandboxes.private`:

```
terraform-sandboxes.private/personal-sandbox/env/sbx-hdeng-1/k8s/deployment.yaml
```

The deployment mounts the ConfigMap as a volume:
Save a deployment that mounts the ConfigMap as a volume to `deployment.yaml`:

```yaml
apiVersion: apps/v1
Expand All @@ -92,7 +78,7 @@ spec:
spec:
containers:
- name: test
image: us-west1-docker.pkg.dev/eng-dev-sbx--hdeng-2/hdeng-images/sentry-options-test:latest
image: REGION-docker.pkg.dev/PROJECT_ID/REPO/sentry-options-test:latest
volumeMounts:
- name: values
mountPath: /etc/sentry-options/values/sentry-options-testing
Expand All @@ -114,14 +100,14 @@ kubectl apply -f deployment.yaml
Watch the pod logs:

```bash
kubectl logs -f deployment/sentry-options-example
kubectl logs -f deployment/sentry-options-test
```

You should see output like:

```
values: UPDATED-VALUE | 0.99 | False
values: UPDATED-VALUE | 0.99 | False
values: UPDATED-VALUE | 0.99 | False | default
values: UPDATED-VALUE | 0.99 | False | default
```

Update a value and re-apply:
Expand All @@ -140,7 +126,7 @@ Update a value and re-apply:
| kubectl apply -f -
```

The pod will pick up the new values within ~5 seconds without restarting.
The pod picks up the new values without restarting. Expect a delay of up to the kubelet sync period (about 1 to 2 minutes) plus the 5 second refresh threshold.

## Directory Structure

Expand Down
Loading