Skip to content

feat: added labelling system#454

Merged
SantiagoDePolonia merged 5 commits into
mainfrom
feat/labelling-system
Jul 2, 2026
Merged

feat: added labelling system#454
SantiagoDePolonia merged 5 commits into
mainfrom
feat/labelling-system

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features
    • Added “Tagging based on headers” configuration with delimiter-based label parsing, optional prefix trimming, and “do not pass upstream” behavior.
    • Added dashboard UI and admin endpoints to view and update header tagging rules (operator-editable vs read-only managed).
    • Captures request-derived labels across usage, audit log, and streaming/realtime paths.
  • Bug Fixes
    • Ensures labels are included consistently on cache hits and streamed events.
    • Enforces “do not pass” tagging headers during upstream forwarding.
  • Documentation
    • Documented tagging settings in the dashboard/config reference, config example, and environment template.
  • Tests
    • Added unit/integration tests for tagging rules, middleware, label extraction, and label propagation.

@SantiagoDePolonia SantiagoDePolonia self-assigned this Jul 1, 2026
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e63e80d-a4f8-4f3f-bf37-8361c6c4bcd2

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd2d0a and d9e9eb0.

📒 Files selected for processing (1)
  • tests/perf/hotpath_test.go

📝 Walkthrough

Walkthrough

This PR adds header-based request tagging across configuration, a new tagging service and persistence layer, admin/server wiring, and label propagation into usage and audit records. It also extends usage storage/read paths to persist and return labels, and updates dashboard UI and docs for tagging rules.

Changes

Request Tagging Feature

Layer / File(s) Summary
Tagging config and env parsing
config/tagging.go, config/tagging_test.go, config/config.go, config/merge.go, config/virtualmodels.go, config/config_test.go, .env.template, CLAUDE.md, config/config.example.yaml
Adds tagging config types, env overlay and normalization logic, shared merge helpers, and the related documentation and config examples.
Tagging rules and stores
internal/tagging/tagging.go, internal/tagging/service.go, internal/tagging/store.go, internal/tagging/store_sqlite.go, internal/tagging/store_postgresql.go, internal/tagging/store_mongodb.go, internal/tagging/factory.go, internal/tagging/*_test.go, internal/core/credential_headers.go, internal/core/credential_headers_test.go
Adds tagging rule normalization and label extraction, the service snapshot and merge logic, the store interface, and SQLite/PostgreSQL/MongoDB persistence implementations with factory wiring and tests.
Server and request plumbing
internal/core/context.go, internal/core/labels.go, internal/server/tagging.go, internal/server/tagging_test.go, internal/server/http.go, internal/server/passthrough_support.go, internal/app/app.go, internal/auditlog/middleware.go, internal/auditlog/stream_wrapper.go, internal/gateway/usage.go, internal/responsecache/usage_hit.go, internal/server/audio_service.go, internal/server/realtime_service.go, internal/server/translated_inference_service.go, internal/server/internal_chat_completion_executor.go, internal/live/broker.go, internal/usage/stream_observer.go
Adds request-context helpers, Echo middleware, server and app lifecycle wiring, and request-path propagation of tagging labels and strip-header state.
Admin tagging settings UI
internal/admin/handler.go, internal/admin/handler_tagging.go, internal/admin/handler_tagging_test.go, internal/admin/routes.go, internal/admin/routes_test.go, internal/admin/dashboard/static/js/modules/tagging.js, internal/admin/dashboard/static/js/dashboard.js, internal/admin/dashboard/templates/layout.html, internal/admin/dashboard/templates/page-settings.html, internal/admin/dashboard/static/css/dashboard.css
Adds the admin tagging settings endpoints, handler wiring, dashboard module loading, and settings page UI for viewing and editing tagging rules.
Usage and audit label propagation
internal/usage/*.go, internal/usage/*_test.go, internal/auditlog/*.go, tests/perf/hotpath_test.go
Adds labels to usage and audit payloads, carries them through readers, writers, stream observers, and previews, and updates the related tests and perf guard.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#158: Both PRs extend the SSE usage-observer path by attaching request-derived labels to emitted usage entries.
  • ENTERPILOT/GoModel#190: Both PRs modify audit logging to carry request-derived metadata through internal/auditlog.
  • ENTERPILOT/GoModel#294: Both PRs touch the admin handler and route registration path used for the new tagging settings endpoints.

Suggested labels: release:feature

Poem

I hopped through headers, crisp and bright,
Found labels hidden left and right.
I tucked them in the logs today,
And nudged the strips to guide the way.
Thump thump! The tagging’s here to stay 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: it announces a new labelling system, which is the core of this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/labelling-system

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 1, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.template:
- Around line 8-19: The tagging env documentation in the .env.template comment
is misleading because applyTaggingEnv() and mergeByKey() replace the entire
TaggingHeaderConfig for a matching header name rather than merging individual
fields. Update the wording around TAGGING_HEADER_* entries to make clear that an
env match overrides the whole header entry from config.yaml, and that unset
_PREFIX, _DELIMITER, or _DONOTPASS values will clear the YAML values instead of
inheriting them.

In `@config/tagging_test.go`:
- Around line 9-98: The tagging tests are written as several nearly identical
one-off cases instead of a table-driven suite. Refactor the checks in
TestApplyTaggingEnv_ParsesAndMerges,
TestApplyTaggingEnv_SortsByIndexAndSkipsGaps, TestApplyTaggingEnv_Unset,
TestNormalizeTaggingConfig, and
TestNormalizeTaggingConfig_RejectsInvalidAndDuplicate into table-driven subtests
so new header options are easier to add and the harness is shared. Keep the same
coverage and assertions, but drive each case from a slice of inputs/expected
results.

In `@config/tagging.go`:
- Around line 83-101: Reject secret-bearing headers during
normalizeTaggingConfig before they are accepted into TaggingConfig.Headers. Add
a small denylist check in normalizeTaggingConfig, using the canonicalized header
name returned by NormalizeHeaderName, and return an error for names like
Authorization, Proxy-Authorization, Cookie, and Set-Cookie (and any equivalent
credential-bearing variants you already treat as sensitive). Keep the existing
duplicate and delimiter handling in place, but fail fast as soon as a denied
header is encountered.

In `@internal/admin/dashboard/static/js/dashboard.js`:
- Around line 257-259: The tagging/settings refresh is only triggered from
_applyRoute(), so the new settings panel can stay stale when submitApiKey() or
refreshDashboardDataAfterRuntimeRefresh() run on /settings. Extract the existing
fetchTaggingSettings() call into a shared helper on the dashboard object and
invoke that helper from _applyRoute(), submitApiKey(), and
refreshDashboardDataAfterRuntimeRefresh() so settings are reloaded regardless of
route changes.

In `@internal/admin/dashboard/templates/page-settings.html`:
- Around line 226-233: The “Add Header” action is missing the loading guard, so
users can add rows while `fetchTaggingSettings` is still populating
`taggingHeaders`. Update the `Add Header` button in `page-settings.html` to also
disable during `taggingLoading` (consistent with `Save Tagging Settings`), and
make sure `addTaggingHeader()` cannot run until loading is finished. Use the
existing `taggingLoading`, `taggingEditable`, and `taggingSaving` state checks
to keep the UI behavior consistent.

In `@internal/admin/handler_tagging.go`:
- Around line 85-95: The error classification in isTaggingValidationError is
brittle because it depends on exact error text from internal/tagging. Expose
typed or sentinel errors there (for example ErrManagedHeader,
ErrInvalidHeaderName, ErrDuplicateHeader) and update isTaggingValidationError in
handler_tagging.go to use errors.Is or errors.As instead of strings.Contains.
Keep the caller/storage split behavior the same, but make the handler depend on
stable error types rather than message wording.
- Around line 38-83: Direct coverage is missing for the new tagging handler
methods in Handler, so add tests for TaggingSettings and UpdateTaggingSettings
that exercise the nil h.tagging path, c.Bind failure, SaveRules validation
errors, SaveRules storage/runtime errors, and the successful response shape. Use
the existing handler methods and tagging dependency methods (Rules, Editable,
SaveRules) to verify both returned errors and the JSON payload contents.

In `@internal/admin/routes.go`:
- Around line 42-47: The tagging routes are interleaved in the middle of the
budgets route group, making the route setup harder to scan. In
internal/admin/routes.go, reorder the router registrations in the block
containing g.PUT("/budgets/settings"), h.TaggingSettings,
h.UpdateTaggingSettings, h.ResetBudget, and h.ResetBudgets so the two tagging
routes are moved together either before or after the complete budgets group,
keeping all budgets routes contiguous.

In `@internal/app/app.go`:
- Line 53: App.Shutdown is missing teardown for the new tagging resource, so add
a Close() call for a.tagging alongside the other managed subsystems. Update the
shutdown path in App to include the tagging.Result field in the same cleanup
sequence as the existing resources, using the App.Shutdown method and a.tagging
identifier to ensure the tagging store/service is released on shutdown.

In `@internal/auditlog/auditlog.go`:
- Around line 113-115: `DoNotPass` header values are still being captured into
persisted `Labels`, which leaks sensitive data beyond forwarding. Update the
label extraction path around `ExtractLabels` so headers marked `DoNotPass: true`
are excluded from the returned labels (or only included behind an explicit
separate opt-in), while keeping the strip-set behavior unchanged. Make sure the
fix flows through `LogData.Labels`, `CreateStreamEntry` in `stream_wrapper.go`,
and `UsageEntry.Labels` in `stream_observer.go` so sensitive header values no
longer reach audit logs, usage records, or dashboards.

In `@internal/server/tagging.go`:
- Around line 23-28: The request-label extraction in DoNotPass still allows
secret-bearing headers to be captured, so `service.ExtractLabels` and
`core.WithRequestLabels` need a denylist/redaction step. Update the tagging flow
in the request-handling path to filter or mask sensitive headers like
`Authorization`, `Cookie`, and API-key headers before labels are attached to the
context, while preserving non-sensitive labels and existing
`service.StripHeaders` behavior.

In `@internal/tagging/service.go`:
- Around line 112-134: SaveRules can return a stale merged view when concurrent
callers interleave between validation, store.SaveRules, and s.Refresh. Serialize
the full validate→persist→refresh flow in Service.SaveRules by holding the
service lock across the whole critical section, and avoid deadlock by using a
lock-free refresh path (for example, a refreshLocked helper) instead of calling
Refresh while already holding the mutex. Ensure the returned s.Rules() always
reflects the same save operation that was just persisted.

In `@internal/tagging/store_postgresql.go`:
- Around line 47-55: Add optimistic locking to the ruleset save path:
`PostgreSQLStore.SaveRules` currently unconditionally upserts the singleton row,
so thread an expected revision/etag from the admin PUT flow into the write API
and use it as a precondition before updating `tagging_settings`. Update
`SaveRules` (and the corresponding save methods in the other two stores) to
compare against the stored revision/`updated_at` value and return a conflict
when the caller’s expected value is stale, instead of silently overwriting the
latest rules.

In `@tests/perf/hotpath_test.go`:
- Around line 399-400: The allocation ceiling in the benchmark is being relaxed
without exercising the labeled request path. Update the benchmark setup in
hotpath_test.go so the case covering usage.NewStreamUsageObserver and the
LogData construction uses non-nil labels/tagged request data, then re-run the
benchmark for that labeled path before keeping the new maxAllocs/maxBytes
thresholds. Use the existing benchmark helpers around
usage.NewStreamUsageObserver and auditlog.LogData to ensure the regression guard
measures the actual tagged-request flow introduced by this PR.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: aa423c86-7695-4fca-a26e-eed85f429b57

📥 Commits

Reviewing files that changed from the base of the PR and between 4a951ae and f86e44b.

📒 Files selected for processing (54)
  • .env.template
  • CLAUDE.md
  • config/config.example.yaml
  • config/config.go
  • config/config_test.go
  • config/merge.go
  • config/tagging.go
  • config/tagging_test.go
  • config/virtualmodels.go
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/tagging.js
  • internal/admin/dashboard/templates/layout.html
  • internal/admin/dashboard/templates/page-settings.html
  • internal/admin/handler.go
  • internal/admin/handler_tagging.go
  • internal/admin/routes.go
  • internal/admin/routes_test.go
  • internal/app/app.go
  • internal/auditlog/auditlog.go
  • internal/auditlog/middleware.go
  • internal/auditlog/stream_wrapper.go
  • internal/core/context.go
  • internal/core/labels.go
  • internal/gateway/usage.go
  • internal/live/broker.go
  • internal/responsecache/usage_hit.go
  • internal/server/audio_service.go
  • internal/server/http.go
  • internal/server/internal_chat_completion_executor.go
  • internal/server/passthrough_support.go
  • internal/server/realtime_service.go
  • internal/server/tagging.go
  • internal/server/tagging_test.go
  • internal/server/translated_inference_service.go
  • internal/tagging/factory.go
  • internal/tagging/service.go
  • internal/tagging/store.go
  • internal/tagging/store_mongodb.go
  • internal/tagging/store_postgresql.go
  • internal/tagging/store_sqlite.go
  • internal/tagging/tagging.go
  • internal/tagging/tagging_test.go
  • internal/usage/labels_sqlite_test.go
  • internal/usage/reader.go
  • internal/usage/reader_mongodb.go
  • internal/usage/reader_postgresql.go
  • internal/usage/reader_sqlite.go
  • internal/usage/store_postgresql.go
  • internal/usage/store_postgresql_test.go
  • internal/usage/store_sqlite.go
  • internal/usage/stream_observer.go
  • internal/usage/usage.go
  • tests/perf/hotpath_test.go

Comment thread .env.template
Comment thread config/tagging_test.go
Comment on lines +9 to +98
func TestApplyTaggingEnv_ParsesAndMerges(t *testing.T) {
cfg := &Config{Tagging: TaggingConfig{Headers: []TaggingHeaderConfig{
{Header: "X-Team", Prefix: "team-"},
{Header: "X-Keep"},
}}}
t.Setenv("TAGGING_HEADER_1", "X-Team")
t.Setenv("TAGGING_HEADER_1_DONOTPASS", "true")
t.Setenv("TAGGING_HEADER_2", "X-Cost-Center")
t.Setenv("TAGGING_HEADER_2_PREFIX", "cc-")
t.Setenv("TAGGING_HEADER_2_DELIMITER", ";")

if err := applyTaggingEnv(cfg); err != nil {
t.Fatalf("applyTaggingEnv() error = %v", err)
}
headers := cfg.Tagging.Headers
if len(headers) != 3 {
t.Fatalf("merged len = %d, want 3: %#v", len(headers), headers)
}
// "X-Team" is overridden in place (env wins) and keeps its position.
team := headers[0]
if team.Header != "X-Team" || !team.DoNotPass || team.Prefix != "" {
t.Fatalf("env did not override X-Team: %#v", team)
}
// "X-Keep" is untouched; "X-Cost-Center" is appended.
if headers[1].Header != "X-Keep" {
t.Fatalf("merge order wrong: %#v", headers)
}
cc := headers[2]
if cc.Header != "X-Cost-Center" || cc.Prefix != "cc-" || cc.Delimiter != ";" || cc.DoNotPass {
t.Fatalf("env entry wrong: %#v", cc)
}
}

func TestApplyTaggingEnv_SortsByIndexAndSkipsGaps(t *testing.T) {
cfg := &Config{}
t.Setenv("TAGGING_HEADER_10", "X-Ten")
t.Setenv("TAGGING_HEADER_2", "X-Two")

if err := applyTaggingEnv(cfg); err != nil {
t.Fatalf("applyTaggingEnv() error = %v", err)
}
headers := cfg.Tagging.Headers
if len(headers) != 2 || headers[0].Header != "X-Two" || headers[1].Header != "X-Ten" {
t.Fatalf("index ordering wrong: %#v", headers)
}
}

func TestApplyTaggingEnv_Unset(t *testing.T) {
cfg := &Config{Tagging: TaggingConfig{Headers: []TaggingHeaderConfig{{Header: "X-Team"}}}}
if err := applyTaggingEnv(cfg); err != nil {
t.Fatalf("applyTaggingEnv() error = %v", err)
}
if len(cfg.Tagging.Headers) != 1 {
t.Fatalf("no env vars mutated config: %#v", cfg.Tagging.Headers)
}
}

func TestNormalizeTaggingConfig(t *testing.T) {
cfg := &TaggingConfig{Headers: []TaggingHeaderConfig{
{Header: "x-team "},
{Header: "X-Cost-Center", Delimiter: ";"},
}}
if err := normalizeTaggingConfig(cfg); err != nil {
t.Fatalf("normalizeTaggingConfig() error = %v", err)
}
if cfg.Headers[0].Header != "X-Team" {
t.Fatalf("header not canonicalized: %#v", cfg.Headers[0])
}
if cfg.Headers[0].Delimiter != DefaultTaggingDelimiter {
t.Fatalf("default delimiter not applied: %#v", cfg.Headers[0])
}
if cfg.Headers[1].Delimiter != ";" {
t.Fatalf("explicit delimiter overwritten: %#v", cfg.Headers[1])
}
}

func TestNormalizeTaggingConfig_RejectsInvalidAndDuplicate(t *testing.T) {
invalid := &TaggingConfig{Headers: []TaggingHeaderConfig{{Header: "bad header"}}}
if err := normalizeTaggingConfig(invalid); err == nil {
t.Fatalf("expected error for invalid header name")
}
empty := &TaggingConfig{Headers: []TaggingHeaderConfig{{Header: " "}}}
if err := normalizeTaggingConfig(empty); err == nil {
t.Fatalf("expected error for empty header name")
}
dup := &TaggingConfig{Headers: []TaggingHeaderConfig{{Header: "X-Team"}, {Header: "x-team"}}}
if err := normalizeTaggingConfig(dup); err == nil {
t.Fatalf("expected error for duplicate header name")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer a table-driven suite for the small env/normalize cases.

Lines 9-98 repeat the same one-case harness several times. Collapsing those cases into a table will make it cheaper to extend the new tagging matrix as more header options are added.
As per coding guidelines, **/*_test.go: Prefer table-driven tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/tagging_test.go` around lines 9 - 98, The tagging tests are written as
several nearly identical one-off cases instead of a table-driven suite. Refactor
the checks in TestApplyTaggingEnv_ParsesAndMerges,
TestApplyTaggingEnv_SortsByIndexAndSkipsGaps, TestApplyTaggingEnv_Unset,
TestNormalizeTaggingConfig, and
TestNormalizeTaggingConfig_RejectsInvalidAndDuplicate into table-driven subtests
so new header options are easier to add and the harness is shared. Keep the same
coverage and assertions, but drive each case from a slice of inputs/expected
results.

Source: Coding guidelines

Comment thread config/tagging.go
Comment on lines +257 to +259
if (typeof this.fetchTaggingSettings === "function") {
this.fetchTaggingSettings();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retry settings-page fetches outside route changes.

Lines 257-259 only load tagging settings from _applyRoute(), but submitApiKey() and refreshDashboardDataAfterRuntimeRefresh() never call fetchTaggingSettings(). Opening /settings while unauthenticated, or refreshing runtime on that page, leaves the new panel stale until the user navigates away and back. Please move the settings fetches behind a shared helper and invoke it from those flows too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/dashboard.js` around lines 257 - 259, The
tagging/settings refresh is only triggered from _applyRoute(), so the new
settings panel can stay stale when submitApiKey() or
refreshDashboardDataAfterRuntimeRefresh() run on /settings. Extract the existing
fetchTaggingSettings() call into a shared helper on the dashboard object and
invoke that helper from _applyRoute(), submitApiKey(), and
refreshDashboardDataAfterRuntimeRefresh() so settings are reloaded regardless of
route changes.

Comment thread internal/admin/dashboard/templates/page-settings.html
Comment on lines +113 to +115
// Labels are request labels extracted from configured tagging headers.
Labels []string `json:"labels,omitempty" bson:"labels,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

DoNotPass headers still leak their values into persisted Labels.

Per internal/server/tagging_test.go's TestTaggingCaptureExtractsLabelsAndStripSet, a header configured with DoNotPass: true (e.g. X-Secret-Tag) still has its value extracted into the labels slice ("internal" shows up in the expected labels). DoNotPass only adds the header to the strip set for upstream forwarding — it does not exclude the value from ExtractLabels. Since Labels is now persisted here in LogData (and propagated through CreateStreamEntry in internal/auditlog/stream_wrapper.go and into UsageEntry.Labels in internal/usage/stream_observer.go), any header an operator flags as "do not pass" for sensitivity reasons still ends up durably stored in audit logs, usage records, and admin dashboards. This likely contradicts operator intent for a header explicitly marked as not to be passed through.

Consider excluding DoNotPass header values from ExtractLabels (or gating them behind a separate flag) so a header marked sensitive is neither forwarded nor logged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auditlog/auditlog.go` around lines 113 - 115, `DoNotPass` header
values are still being captured into persisted `Labels`, which leaks sensitive
data beyond forwarding. Update the label extraction path around `ExtractLabels`
so headers marked `DoNotPass: true` are excluded from the returned labels (or
only included behind an explicit separate opt-in), while keeping the strip-set
behavior unchanged. Make sure the fix flows through `LogData.Labels`,
`CreateStreamEntry` in `stream_wrapper.go`, and `UsageEntry.Labels` in
`stream_observer.go` so sensitive header values no longer reach audit logs,
usage records, or dashboards.

Comment thread internal/server/tagging.go
Comment on lines +112 to +134
func (s *Service) SaveRules(ctx context.Context, rules []Rule) ([]Rule, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("tagging rules storage is not available")
}
if err := NormalizeRules(rules); err != nil {
return nil, err
}
for _, rule := range rules {
if s.isManagedHeader(rule.Header) {
return nil, fmt.Errorf("header %q is managed by config/env and is read-only", rule.Header)
}
}
for i := range rules {
rules[i].Managed = false
}
if err := s.store.SaveRules(ctx, rules); err != nil {
return nil, fmt.Errorf("save tagging rules: %w", err)
}
if err := s.Refresh(ctx); err != nil {
return nil, err
}
return s.Rules(), nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Concurrent SaveRules calls can return a stale/mismatched merged view.

SaveRules validates, persists via s.store.SaveRules, then calls s.Refresh — but nothing serializes the whole validate→persist→refresh sequence across concurrent callers. If two admin saves (A, B) interleave, A's store.SaveRules(A) can be immediately overwritten by B's store.SaveRules(B), and if A's Refresh runs after B's write, A's call returns s.Rules() reflecting B's data while claiming to be the result of A's save. The persisted store state stays consistent (last-writer-wins), but the API response returned to caller A is silently wrong.

Widening the lock to cover the full critical section fixes this:

🔒 Proposed fix: serialize SaveRules end-to-end
 func (s *Service) SaveRules(ctx context.Context, rules []Rule) ([]Rule, error) {
 	if s == nil || s.store == nil {
 		return nil, fmt.Errorf("tagging rules storage is not available")
 	}
 	if err := NormalizeRules(rules); err != nil {
 		return nil, err
 	}
 	for _, rule := range rules {
 		if s.isManagedHeader(rule.Header) {
 			return nil, fmt.Errorf("header %q is managed by config/env and is read-only", rule.Header)
 		}
 	}
 	for i := range rules {
 		rules[i].Managed = false
 	}
+	s.refreshMu.Lock()
+	defer s.refreshMu.Unlock()
 	if err := s.store.SaveRules(ctx, rules); err != nil {
 		return nil, fmt.Errorf("save tagging rules: %w", err)
 	}
-	if err := s.Refresh(ctx); err != nil {
+	if err := s.refreshLocked(ctx); err != nil {
 		return nil, err
 	}
 	return s.Rules(), nil
 }

(Refresh would need a lock-free refreshLocked variant, or restructure Refresh to accept an already-held lock, to avoid deadlocking on the mutex.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tagging/service.go` around lines 112 - 134, SaveRules can return a
stale merged view when concurrent callers interleave between validation,
store.SaveRules, and s.Refresh. Serialize the full validate→persist→refresh flow
in Service.SaveRules by holding the service lock across the whole critical
section, and avoid deadlock by using a lock-free refresh path (for example, a
refreshLocked helper) instead of calling Refresh while already holding the
mutex. Ensure the returned s.Rules() always reflects the same save operation
that was just persisted.

Comment on lines +47 to +55
func (s *PostgreSQLStore) SaveRules(ctx context.Context, rules []Rule) error {
value, err := encodeRules(rules)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO tagging_settings (key, value, updated_at) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
`, rulesSettingKey, string(value), time.Now().Unix())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add optimistic locking for ruleset saves.

This upsert replaces the singleton rules row unconditionally. With the new admin GET/PUT flow, concurrent edits become silent last-write-wins, so one admin can overwrite another without any conflict signal. updated_at is already stored, but it is never used as a write precondition. Please thread an expected revision/etag through the service API and make the update conditional across all three stores.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tagging/store_postgresql.go` around lines 47 - 55, Add optimistic
locking to the ruleset save path: `PostgreSQLStore.SaveRules` currently
unconditionally upserts the singleton row, so thread an expected revision/etag
from the admin PUT flow into the write API and use it as a precondition before
updating `tagging_settings`. Update `SaveRules` (and the corresponding save
methods in the other two stores) to compare against the stored
revision/`updated_at` value and return a conflict when the caller’s expected
value is stale, instead of silently overwriting the latest rules.

Comment thread tests/perf/hotpath_test.go Outdated
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge. The labelling system is well-contained, all usage/audit write paths consistently attach labels, and the database migrations are idempotent.

The feature is broadly correct and thoroughly tested. The one inline comment describes a narrow TOCTOU window during live config updates — labels and the header-strip set are read from two independent atomic snapshots in the middleware, which could diverge if a Refresh fires between them. The impact is limited to a single in-flight request during a rare admin operation, and both snapshots represent valid states. No data loss, no security gap, and no persistent inconsistency.

internal/server/tagging.go — the two-snapshot read in TaggingCapture is worth a follow-up to make labels and strip-set extraction atomic.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant TaggingCapture as TaggingCapture (middleware)
    participant AuditMiddleware as Audit Middleware
    participant Handler as Request Handler
    participant Gateway as Gateway / Usage Logger
    participant Store as Usage/Audit Store

    Client->>TaggingCapture: HTTP Request (with tagging headers)
    TaggingCapture->>TaggingCapture: ExtractLabels(snapshot.rules)
    TaggingCapture->>TaggingCapture: StripHeaders(snapshot.strip) [2nd snapshot load]
    TaggingCapture->>AuditMiddleware: ctx with labels + strip set
    AuditMiddleware->>AuditMiddleware: read labels from ctx → LogData.Labels
    AuditMiddleware->>Handler: forward request
    Handler->>Gateway: InferenceOrchestrator.logUsage(ctx)
    Gateway->>Gateway: "entry.Labels = RequestLabelsFromContext(ctx)"
    Gateway->>Store: Write(entry with labels)
    Handler->>Client: Response

    note over TaggingCapture: snapshot.strip used in buildPassthroughHeaders to drop DoNotPass headers before upstream forward
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant TaggingCapture as TaggingCapture (middleware)
    participant AuditMiddleware as Audit Middleware
    participant Handler as Request Handler
    participant Gateway as Gateway / Usage Logger
    participant Store as Usage/Audit Store

    Client->>TaggingCapture: HTTP Request (with tagging headers)
    TaggingCapture->>TaggingCapture: ExtractLabels(snapshot.rules)
    TaggingCapture->>TaggingCapture: StripHeaders(snapshot.strip) [2nd snapshot load]
    TaggingCapture->>AuditMiddleware: ctx with labels + strip set
    AuditMiddleware->>AuditMiddleware: read labels from ctx → LogData.Labels
    AuditMiddleware->>Handler: forward request
    Handler->>Gateway: InferenceOrchestrator.logUsage(ctx)
    Gateway->>Gateway: "entry.Labels = RequestLabelsFromContext(ctx)"
    Gateway->>Store: Write(entry with labels)
    Handler->>Client: Response

    note over TaggingCapture: snapshot.strip used in buildPassthroughHeaders to drop DoNotPass headers before upstream forward
Loading

Reviews (2): Last reviewed commit: "fix(tagging): address review findings" | Re-trigger Greptile

Comment thread internal/admin/handler_tagging.go Outdated
- close the tagging subsystem during app shutdown
- classify admin save errors with a typed ValidationError instead of
  substring matching (a storage error mentioning "read-only" was
  previously misreported as HTTP 400)
- reject credential-bearing headers (Authorization, Cookie, API-key
  headers) as tagging label sources in config and admin validation
- disable "Add Header" while settings load; group tagging routes after
  the budgets block; document that env entries replace whole YAML
  entries per header
- exercise the labelled path in the streaming perf guard; add
  handler_tagging tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/tagging/service.go (1)

112-134: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Concurrent SaveRules calls can still return a mismatched merged view.

The error-classification fix (typed ValidationError for the managed-header case) is in place, but the underlying race flagged previously is still present: nothing serializes the validate → s.store.SaveRuless.Refresh sequence across concurrent callers, so caller A can receive s.Rules() reflecting a concurrently-interleaved caller B's write while believing it reflects its own save.

🔒 Suggested fix: serialize SaveRules end-to-end
 func (s *Service) SaveRules(ctx context.Context, rules []Rule) ([]Rule, error) {
 	if s == nil || s.store == nil {
 		return nil, fmt.Errorf("tagging rules storage is not available")
 	}
 	if err := NormalizeRules(rules); err != nil {
 		return nil, err
 	}
 	for _, rule := range rules {
 		if s.isManagedHeader(rule.Header) {
 			return nil, newValidationError("header %q is managed by config/env and is read-only", rule.Header)
 		}
 	}
 	for i := range rules {
 		rules[i].Managed = false
 	}
+	s.refreshMu.Lock()
+	defer s.refreshMu.Unlock()
 	if err := s.store.SaveRules(ctx, rules); err != nil {
 		return nil, fmt.Errorf("save tagging rules: %w", err)
 	}
-	if err := s.Refresh(ctx); err != nil {
+	if err := s.refreshLocked(ctx); err != nil {
 		return nil, err
 	}
 	return s.Rules(), nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tagging/service.go` around lines 112 - 134, SaveRules still has a
race because concurrent callers can interleave validation, store writes, and
Refresh, causing the returned s.Rules() snapshot to reflect another caller’s
update. Serialize the full SaveRules flow end-to-end in the Service type
(including NormalizeRules, managed-header checks, s.store.SaveRules, and
s.Refresh) using the existing service-level synchronization mechanism or a new
mutex around the method so each caller sees its own committed view.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/tagging.go`:
- Around line 40-55: The sensitive-header denylist is duplicated between
config/tagging.go and internal/auditlog/auditlog.go, which makes it easy to
drift out of sync. Move the credential-bearing header list into a shared source
of truth that both tagging and audit logging can reference without creating an
import cycle, then update deniedTaggingHeaders and the auditlog redaction logic
to use that shared set.

---

Duplicate comments:
In `@internal/tagging/service.go`:
- Around line 112-134: SaveRules still has a race because concurrent callers can
interleave validation, store writes, and Refresh, causing the returned s.Rules()
snapshot to reflect another caller’s update. Serialize the full SaveRules flow
end-to-end in the Service type (including NormalizeRules, managed-header checks,
s.store.SaveRules, and s.Refresh) using the existing service-level
synchronization mechanism or a new mutex around the method so each caller sees
its own committed view.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 621fdb8a-5212-4f0f-9efb-c853f49c3df8

📥 Commits

Reviewing files that changed from the base of the PR and between f86e44b and 577184f.

📒 Files selected for processing (14)
  • .env.template
  • CLAUDE.md
  • config/config.example.yaml
  • config/tagging.go
  • config/tagging_test.go
  • internal/admin/dashboard/templates/page-settings.html
  • internal/admin/handler_tagging.go
  • internal/admin/handler_tagging_test.go
  • internal/admin/routes.go
  • internal/app/app.go
  • internal/tagging/service.go
  • internal/tagging/tagging.go
  • internal/tagging/tagging_test.go
  • tests/perf/hotpath_test.go

Comment thread config/tagging.go Outdated
SantiagoDePolonia and others added 2 commits July 2, 2026 14:29
config/tagging.go and auditlog.RedactedHeaders each kept their own copy
of the credential-bearing header list, held in sync only by a comment.
Move the list into core.IsCredentialHeader as the single source of
truth; audit redaction and both tagging validation paths now share it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	internal/usage/stream_observer.go
#	tests/perf/hotpath_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/usage/stream_observer.go (1)

59-65: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

entry.Labels aliases the observer's shared slice instead of copying.

entry.Labels = o.labels stores a direct reference to the observer's backing array. If extractUsageFromEvent fires more than once per stream (interim + final usage), every emitted UsageEntry shares the same slice as o.labels. Elsewhere in this PR (internal/live/broker.go's usagePreviewFromEntry) the same Labels field is defensively copied before reuse, suggesting the intended contract is that Labels ownership shouldn't be shared. A downstream mutation (e.g., append with spare capacity, in-place sort/redaction) on one entry's Labels could silently corrupt another entry's or the observer's own labels.

🛡️ Proposed fix
-		entry.Labels = o.labels
+		entry.Labels = append([]string(nil), o.labels...)

Also applies to: 178-178

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/stream_observer.go` around lines 59 - 65, The Labels field is
being shared by reference instead of owned per entry, so each UsageEntry can end
up aliasing StreamUsageObserver’s backing slice. Update
StreamUsageObserver.SetLabels and the UsageEntry creation path in
extractUsageFromEvent so labels are copied before assignment, and keep the same
defensive-copy contract used by usagePreviewFromEntry in
internal/live/broker.go. Ensure every emitted UsageEntry gets its own slice
rather than reusing o.labels.
♻️ Duplicate comments (1)
internal/auditlog/auditlog.go (1)

115-117: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Confirm DoNotPass header values are excluded before landing in Labels.

This field persists whatever ExtractLabels produces into audit logs/usage records/dashboards. A prior review on this exact field flagged that headers marked DoNotPass: true (e.g., a secret-tag header) were still being extracted into labels — DoNotPass only affects upstream forwarding, not label extraction. If that hasn't been fixed upstream in internal/server/tagging.go, this field is where the leak becomes durably persisted. Please confirm ExtractLabels now excludes DoNotPass headers (or gates them behind an explicit opt-in) before this field is populated.

#!/bin/bash
# Inspect ExtractLabels to confirm DoNotPass headers are excluded from label extraction
rg -n -A15 'func.*ExtractLabels' --type=go
rg -n 'DoNotPass' --type=go -C5
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auditlog/auditlog.go` around lines 115 - 117, Confirm that
`ExtractLabels` in `internal/server/tagging.go` filters out any tagging headers
marked `DoNotPass` before assigning results to `auditlog.AuditLog.Labels`.
Update the extraction logic so only explicitly allowed headers are returned, or
add an opt-in gate for secret-tag headers if needed. Then verify the `Labels`
field in `internal/auditlog/auditlog.go` only receives the sanitized output and
cannot persist `DoNotPass` values into audit logs or usage records.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/usage/stream_observer.go`:
- Around line 59-65: The Labels field is being shared by reference instead of
owned per entry, so each UsageEntry can end up aliasing StreamUsageObserver’s
backing slice. Update StreamUsageObserver.SetLabels and the UsageEntry creation
path in extractUsageFromEvent so labels are copied before assignment, and keep
the same defensive-copy contract used by usagePreviewFromEntry in
internal/live/broker.go. Ensure every emitted UsageEntry gets its own slice
rather than reusing o.labels.

---

Duplicate comments:
In `@internal/auditlog/auditlog.go`:
- Around line 115-117: Confirm that `ExtractLabels` in
`internal/server/tagging.go` filters out any tagging headers marked `DoNotPass`
before assigning results to `auditlog.AuditLog.Labels`. Update the extraction
logic so only explicitly allowed headers are returned, or add an opt-in gate for
secret-tag headers if needed. Then verify the `Labels` field in
`internal/auditlog/auditlog.go` only receives the sanitized output and cannot
persist `DoNotPass` values into audit logs or usage records.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1abd6c30-5da7-4fe2-8fb0-0a4cc9144dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 577184f and 7dd2d0a.

📒 Files selected for processing (8)
  • config/tagging.go
  • internal/auditlog/auditlog.go
  • internal/core/credential_headers.go
  • internal/core/credential_headers_test.go
  • internal/live/broker.go
  • internal/tagging/tagging.go
  • internal/usage/stream_observer.go
  • tests/perf/hotpath_test.go

Alloc counts are identical across linux/amd64 CI and darwin/arm64
local runs, so every ceiling now sits at baseline +2 allocs with a
few hundred bytes of headroom (the responses converter keeps extra
bytes for pool cold-starts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SantiagoDePolonia SantiagoDePolonia merged commit 4c8e0d7 into main Jul 2, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants