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
54 changes: 46 additions & 8 deletions docs/reference/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,24 @@ Accepts `EvaluationContext` objects or plain dicts (`{"targeting_key": user_id,

---

### `await engine.save_flag(flag)`
### `await engine.save_flag(flag, *, actor, platform, action, audit)`

Create or replace a flag.
Create or replace a flag. Writes an audit log entry unless `audit=False`.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `flag` | `FeatureFlag` | required | Flag to persist |
| `actor` | `str` | `"system"` | Identity recorded in the audit log |
| `platform` | `str` | `""` | Surface recorded in the audit log (`"dashboard"`, `"cli"`, etc.) |
| `action` | `str \| None` | `None` | Override the audit action string. Defaults to `flag_created` or `flag_updated` based on whether the flag already existed |
| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization |

```python
# Normal save — audited
await engine.save_flag(FeatureFlag(key="my-flag", ...))

# Startup seed — no audit entry
await engine.save_flag(FeatureFlag(key="my-flag", ...), audit=False)
```

---
Expand All @@ -76,15 +88,34 @@ Return all flags as a list.

---

### `await engine.delete_flag(key)`
### `await engine.delete_flag(key, *, actor, platform, audit)`

Delete a flag.
Delete a flag. Writes an audit log entry unless `audit=False`.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `key` | `str` | required | Key of the flag to delete |
| `actor` | `str` | `"system"` | Identity recorded in the audit log |
| `platform` | `str` | `""` | Surface recorded in the audit log |
| `audit` | `bool` | `True` | Set to `False` to skip the audit entry |

---

### `await engine.save_segment(segment)`
### `await engine.save_segment(segment, *, actor, platform, audit)`

Create or replace a segment. Writes an audit log entry unless `audit=False`.

Create or replace a segment.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `segment` | `Segment` | required | Segment to persist |
| `actor` | `str` | `"system"` | Identity recorded in the audit log |
| `platform` | `str` | `""` | Surface recorded in the audit log |
| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization |

```python
# Startup seed — no audit entry
await engine.save_segment(Segment(key="beta-users", ...), audit=False)
```

---

Expand All @@ -100,9 +131,16 @@ Return all segments as a list.

---

### `await engine.delete_segment(key)`
### `await engine.delete_segment(key, *, actor, platform, audit)`

Delete a segment. Writes an audit log entry unless `audit=False`.

Delete a segment.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `key` | `str` | required | Key of the segment to delete |
| `actor` | `str` | `"system"` | Identity recorded in the audit log |
| `platform` | `str` | `""` | Surface recorded in the audit log |
| `audit` | `bool` | `True` | Set to `False` to skip the audit entry |

---

Expand Down
12 changes: 12 additions & 0 deletions docs/tutorial/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx)

Rollout weights are integers out of `100_000`. The above gives exactly 20% to `"on"` and 80% to `"off"`. Bucketing is deterministic: the same `user_id` always lands in the same bucket.

!!! tip "Seeding flags at startup"
When pre-loading flags in a lifespan or startup function, pass `audit=False` so these programmatic writes do not appear in the audit log:
```python
@asynccontextmanager
async def lifespan(_):
await engine.save_flag(FeatureFlag(key="new-checkout", ...), audit=False)
yield
```
Flags created or updated through the dashboard, REST API, or CLI always audit regardless of this parameter.

---

## Flag types
Expand Down Expand Up @@ -260,6 +270,8 @@ await engine.save_segment(Segment(
))
```

Pass `audit=False` when seeding segments at startup, same as with flags.

### Segment evaluation order

For a given context key `k`:
Expand Down
15 changes: 10 additions & 5 deletions examples/fastapi/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ async def _seed_flags() -> None:
RolloutVariation(variation="off", weight=80_000),
],
targets={"on": ["beta_tester_1", "beta_tester_2"]},
)
),
audit=False,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -166,7 +167,8 @@ async def _seed_flags() -> None:
variation="dark",
)
],
)
),
audit=False,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -200,7 +202,8 @@ async def _seed_flags() -> None:
variation="large",
)
],
)
),
audit=False,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -241,7 +244,8 @@ async def _seed_flags() -> None:
variation="eu",
),
],
)
),
audit=False,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -280,7 +284,8 @@ async def _seed_flags() -> None:
],
off_variation="v1",
fallthrough="v1",
)
),
audit=False,
)


Expand Down
22 changes: 13 additions & 9 deletions shield/admin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,7 @@ async def create_flag(request: Request) -> JSONResponse:
if existing is not None:
return _err(f"Flag '{flag.key}' already exists. Use PUT to update.", 409)

await _engine(request).save_flag(flag)
await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request))
return JSONResponse(flag.model_dump(mode="json"), status_code=201)


Expand Down Expand Up @@ -964,7 +964,7 @@ async def update_flag(request: Request) -> JSONResponse:
except Exception as exc:
return _err(f"Invalid flag definition: {exc}")

await _engine(request).save_flag(flag)
await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request))
return JSONResponse(flag.model_dump(mode="json"))


Expand Down Expand Up @@ -1005,7 +1005,7 @@ async def patch_flag(request: Request) -> JSONResponse:
if isinstance(updated.fallthrough, str) and updated.fallthrough not in variation_names:
return _err(f"fallthrough '{updated.fallthrough}' does not match any variation name")

await _engine(request).save_flag(updated)
await _engine(request).save_flag(updated, actor=_actor(request), platform=_platform(request))
return JSONResponse(updated.model_dump(mode="json"))


Expand All @@ -1018,7 +1018,9 @@ async def enable_flag(request: Request) -> JSONResponse:
if flag is None:
return _err(f"Flag '{key}' not found", 404)
flag = flag.model_copy(update={"enabled": True})
await _engine(request).save_flag(flag)
await _engine(request).save_flag(
flag, actor=_actor(request), platform=_platform(request), action="flag_enabled"
)
return JSONResponse(flag.model_dump(mode="json"))


Expand All @@ -1031,7 +1033,9 @@ async def disable_flag(request: Request) -> JSONResponse:
if flag is None:
return _err(f"Flag '{key}' not found", 404)
flag = flag.model_copy(update={"enabled": False})
await _engine(request).save_flag(flag)
await _engine(request).save_flag(
flag, actor=_actor(request), platform=_platform(request), action="flag_disabled"
)
return JSONResponse(flag.model_dump(mode="json"))


Expand All @@ -1043,7 +1047,7 @@ async def delete_flag(request: Request) -> JSONResponse:
existing = await _engine(request).get_flag(key)
if existing is None:
return _err(f"Flag '{key}' not found", 404)
await _engine(request).delete_flag(key)
await _engine(request).delete_flag(key, actor=_actor(request), platform=_platform(request))
return JSONResponse({"ok": True, "deleted": key})


Expand Down Expand Up @@ -1144,7 +1148,7 @@ async def create_segment(request: Request) -> JSONResponse:
if existing is not None:
return _err(f"Segment '{segment.key}' already exists. Use PUT to update.", 409)

await _engine(request).save_segment(segment)
await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request))
return JSONResponse(segment.model_dump(mode="json"), status_code=201)


Expand All @@ -1171,7 +1175,7 @@ async def update_segment(request: Request) -> JSONResponse:
except Exception as exc:
return _err(f"Invalid segment definition: {exc}")

await _engine(request).save_segment(segment)
await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request))
return JSONResponse(segment.model_dump(mode="json"))


Expand All @@ -1183,5 +1187,5 @@ async def delete_segment(request: Request) -> JSONResponse:
existing = await _engine(request).get_segment(key)
if existing is None:
return _err(f"Segment '{key}' not found", 404)
await _engine(request).delete_segment(key)
await _engine(request).delete_segment(key, actor=_actor(request), platform=_platform(request))
return JSONResponse({"ok": True, "deleted": key})
16 changes: 16 additions & 0 deletions shield/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,12 +664,28 @@ async def _run_log() -> None:
"rl_policy_updated": "update",
"rl_reset": "reset",
"rl_policy_deleted": "delete",
"flag_created": "created",
"flag_updated": "updated",
"flag_enabled": "enabled",
"flag_disabled": "disabled",
"flag_deleted": "deleted",
"segment_created": "created",
"segment_updated": "updated",
"segment_deleted": "deleted",
}
_rl_action_colours = {
"rl_policy_set": "green",
"rl_policy_updated": "yellow",
"rl_reset": "cyan",
"rl_policy_deleted": "red",
"flag_created": "green",
"flag_updated": "yellow",
"flag_enabled": "green",
"flag_disabled": "dim",
"flag_deleted": "red",
"segment_created": "green",
"segment_updated": "yellow",
"segment_deleted": "red",
}

table = Table(box=box.ROUNDED, show_header=True, header_style="bold")
Expand Down
65 changes: 61 additions & 4 deletions shield/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,17 +681,46 @@ async def get_flag(self, key: str) -> Any:
flags = await self.backend.load_all_flags()
return next((f for f in flags if f.key == key), None)

async def save_flag(self, flag: Any) -> None:
async def save_flag(
self,
flag: Any,
actor: str = "system",
platform: str = "",
action: str | None = None,
audit: bool = True,
) -> None:
"""Persist *flag* to the backend and update the provider cache."""
existing = await self.get_flag(flag.key)
default_action = "flag_created" if existing is None else "flag_updated"
await self.backend.save_flag(flag)
if self._flag_provider is not None:
self._flag_provider.upsert_flag(flag)
if audit:
await self._audit_rl(
path=f"flag:{flag.key}",
action=action or default_action,
actor=actor,
platform=platform,
)

async def delete_flag(self, key: str) -> None:
async def delete_flag(
self,
key: str,
actor: str = "system",
platform: str = "",
audit: bool = True,
) -> None:
"""Delete a flag by *key* from the backend and provider cache."""
await self.backend.delete_flag(key)
if self._flag_provider is not None:
self._flag_provider.delete_flag(key)
if audit:
await self._audit_rl(
path=f"flag:{key}",
action="flag_deleted",
actor=actor,
platform=platform,
)

async def list_segments(self) -> list[Any]:
"""Return all segments from the provider cache (or backend)."""
Expand All @@ -706,17 +735,45 @@ async def get_segment(self, key: str) -> Any:
segments = await self.backend.load_all_segments()
return next((s for s in segments if s.key == key), None)

async def save_segment(self, segment: Any) -> None:
async def save_segment(
self,
segment: Any,
actor: str = "system",
platform: str = "",
audit: bool = True,
) -> None:
"""Persist *segment* to the backend and update the provider cache."""
existing = await self.get_segment(segment.key)
action = "segment_created" if existing is None else "segment_updated"
await self.backend.save_segment(segment)
if self._flag_provider is not None:
self._flag_provider.upsert_segment(segment)
if audit:
await self._audit_rl(
path=f"segment:{segment.key}",
action=action,
actor=actor,
platform=platform,
)

async def delete_segment(self, key: str) -> None:
async def delete_segment(
self,
key: str,
actor: str = "system",
platform: str = "",
audit: bool = True,
) -> None:
"""Delete a segment by *key* from the backend and provider cache."""
await self.backend.delete_segment(key)
if self._flag_provider is not None:
self._flag_provider.delete_segment(key)
if audit:
await self._audit_rl(
path=f"segment:{key}",
action="segment_deleted",
actor=actor,
platform=platform,
)

async def _run_global_config_listener(self) -> None:
"""Background coroutine: invalidate the global config cache on remote changes.
Expand Down
Loading
Loading