Skip to content

fix: Enhance EFV audit logs#7535

Open
SahilJat wants to merge 3 commits into
Flagsmith:mainfrom
SahilJat:feat/enhanced-audit-logs
Open

fix: Enhance EFV audit logs#7535
SahilJat wants to merge 3 commits into
Flagsmith:mainfrom
SahilJat:feat/enhanced-audit-logs

Conversation

@SahilJat
Copy link
Copy Markdown
Contributor

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Closes #7526

This PR resolves the issue of opaque audit logs for v2 versioning (EnvironmentFeatureVersion). Previously, publishing a new version only logged a static string ("New version published for feature: %s"), completely omitting which feature states were actually changed.

This updates create_environment_feature_version_published_audit_log_task to:

  • Render the EFV message via get_updated_feature_states_for_version(efv).
  • Enumerate each changed Feature State's action (enabled/disabled) and scope (Environment default, Segment override, Identity override).
  • Mitigate N+1 database queries by prefetching feature_segment__segment and identity via select_related.

How did you test this code?

-I wrote comprehensive unit tests in api/tests/unit/audit/test_unit_audit_services.py to cover the different scenarios for the audit log task:

  • Verified that if there are no changes, the audit log falls back to the original header-only format.
  • Verified that modifications to the Environment Default are properly appended to the log string.
  • Verified that modifications to a Segment Override append the specific Segment's name to the log string.

@SahilJat SahilJat requested a review from a team as a code owner May 19, 2026 06:09
@SahilJat SahilJat requested review from khvn26 and removed request for a team May 19, 2026 06:09
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 19, 2026

@SahilJat is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the api Issue related to the REST API label May 19, 2026
@SahilJat SahilJat changed the title Enhance EFV audit logs fix: Enhance EFV audit logs May 19, 2026
@SahilJat SahilJat force-pushed the feat/enhanced-audit-logs branch from a776a6c to 5aecaf8 Compare May 19, 2026 06:25
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.31%. Comparing base (82c5dfb) to head (3eb06b6).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7535      +/-   ##
==========================================
- Coverage   98.47%   98.31%   -0.16%     
==========================================
  Files        1400     1400              
  Lines       53036    52960      -76     
==========================================
- Hits        52226    52068     -158     
- Misses        810      892      +82     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@SahilJat SahilJat force-pushed the feat/enhanced-audit-logs branch from 5aecaf8 to 3eb06b6 Compare May 19, 2026 09:43
@SahilJat
Copy link
Copy Markdown
Contributor Author

@khvn26 the test cases that is failing is already existing issue which will be solved with pr #7530 . This issue is also solved please have a look at it. Thanks

@SahilJat SahilJat closed this May 19, 2026
@SahilJat SahilJat reopened this May 19, 2026
@Zaimwa9
Copy link
Copy Markdown
Contributor

Zaimwa9 commented May 22, 2026

@claude review-once

Comment on lines 281 to +302
"environment", "feature"
).get(uuid=environment_feature_version_uuid)

header = (
ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE
% environment_feature_version.feature.name
)

changed_states = get_updated_feature_states_for_version(environment_feature_version)

if changed_states:
changed_states = list(
FeatureState.objects.filter(
id__in=[fs.id for fs in changed_states]
).select_related("feature_segment__segment", "identity")
)
change_lines = "\n".join(
f"- {_build_feature_state_change_summary(fs)}" for fs in changed_states
)
log = f"{header}\n{change_lines}"
else:
log = header
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The new audit log silently omits removed feature states. get_updated_feature_states_for_version() only iterates the new version's feature states and compares them against the previous version — it never walks the previous version looking for keys that don't appear in the new one. So when a user publishes a version whose only change is dropping a segment override (e.g. via segment_ids_to_delete_overrides), the removed override is never visited and the audit log falls back to just the header ("New version published for feature: X"), contradicting the PR's stated goal of enumerating each changed Feature State's action and scope.

Extended reasoning...

What the bug is

create_environment_feature_version_published_audit_log_task (api/features/versioning/tasks.py:281-302) delegates change detection to get_updated_feature_states_for_version (api/features/versioning/versioning_service.py:479-522). That helper iterates only the new version's feature states:

previous_feature_states_map = (
    {get_match_key(fs): fs for fs in previous_version.feature_states.all()}
    if previous_version
    else {}
)

changed_feature_states = []
for feature_state in version.feature_states.all():   # NEW version only
    previous_fs = previous_feature_states_map.get(get_match_key(feature_state))
    ...

It never enumerates the previous map looking for keys absent in the new version, so feature states that existed in the previous version but were removed in the new one are invisible to the audit log.

Why this triggers for deletions

The versioning create serializer (api/features/versioning/serializers.py:312-315) handles segment_ids_to_delete_overrides by hard-deleting the matching feature_segments on the new version:

def _delete_feature_states(self, segment_ids, version):
    version.feature_segments.filter(segment_id__in=segment_ids).delete()

Because FeatureState.feature_segment cascades, the corresponding FeatureState rows are removed from the new version. So new_version.feature_states.all() simply doesn't contain them, and the loop never visits them. A similar single-override path exists in _delete_segment_override_v2 (versioning_service.py:447-474), which also deletes the FS from the new version after cloning.

Step-by-step proof

  1. Environment with v2 versioning enabled; feature F has an active segment override on segment S1 in version V1 (live).
  2. User creates a new version V2 whose only change is segment_ids_to_delete_overrides: [S1.id] and publishes it.
  3. During create, _delete_feature_states([S1.id], V2) is called → V2.feature_segments.filter(segment_id=S1.id).delete() cascades and the cloned segment-override FeatureState in V2 is removed.
  4. V2.feature_states.all() now contains only the environment-default FS, which is identical to V1's default → it matches by (identity_id=None, segment_id=None), all attributes equal, so changed_feature_states is empty.
  5. create_environment_feature_version_published_audit_log_task therefore takes the else: log = header branch.
  6. Resulting audit log is just "New version published for feature: F" — no mention of the removed S1 override.

Impact

The PR description explicitly promises that the audit log enumerates each changed Feature State's action and scope. A pure-deletion publish produces a log that's indistinguishable from a no-op publish, which is misleading for users auditing what changed. The same blind spot exists in the parallel webhook code (_trigger_feature_state_webhooks_for_version, tasks.py:154-240), so this is a pre-existing limitation of the helper — but this PR is the first to surface it in a user-facing audit message.

How to fix

Either extend get_updated_feature_states_for_version to also yield previous-version FS whose keys are absent in the new version (tagged as removals), or add a parallel removal-detection step inside create_environment_feature_version_published_audit_log_task that diffs previous_version.feature_states against the new version's keys and renders something like - Segment override (S1): removed. The summary builder in this PR (_build_feature_state_change_summary) would need a small extension to render a "removed" state, or a separate helper for removed states.

Comment on lines +264 to +272
def _build_feature_state_change_summary(fs: FeatureState) -> str:
if fs.identity_id:
scope = f"Identity override ({fs.identity.identifier})" # type: ignore[union-attr]
elif fs.feature_segment_id:
scope = f"Segment override ({fs.feature_segment.segment.name})" # type: ignore[union-attr]
else:
scope = "Environment default"

state = "enabled" if fs.enabled else "disabled"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The audit-log summary only reports each changed feature state's scope and current enabled/disabled flag, but get_updated_feature_states_for_version also flags states whose feature_state_value or multivariate values changed while enabled stayed the same. Such value-only edits produce a line indistinguishable from an enabled-toggle change (e.g. - Environment default: enabled), defeating this PR's stated goal of making EFV audit logs less opaque. Consider including the value/MV diff (or at least marking which attribute changed) in _build_feature_state_change_summary, and add a regression test for a value-only change.

Extended reasoning...

What the bug is

The new _build_feature_state_change_summary (api/features/versioning/tasks.py:264-272) only renders two pieces of information for each changed feature state: its scope (Environment default / Segment override (...) / Identity override (...)) and the current enabled/disabled flag. It never inspects (or surfaces) the feature-state value or the multivariate allocations.

However, get_updated_feature_states_for_version (api/features/versioning/versioning_service.py:510-522) classifies a feature state as "changed" if any of the following differs from the previous version:

if previous_fs is None or (
    feature_state.enabled != previous_fs.enabled
    or feature_state.get_feature_state_value()
       != previous_fs.get_feature_state_value()
    or multivariate_values_changed(feature_state, previous_fs)
):
    changed_feature_states.append(feature_state)

So a feature state whose value changed (e.g. string_value "v1""v2") or whose multivariate allocation changed, with enabled unchanged, will be in the changed list — but the audit log line will only say the current enabled state.

Step-by-step proof

  1. Start with an EFV where the env-default feature state has enabled=True, string_value="v1".
  2. Create a new version, copy the previous states, then edit only the env-default value to "v2" (still enabled=True).
  3. Publish. create_environment_feature_version_published_audit_log_task runs.
  4. get_updated_feature_states_for_version returns that env-default FS (value differs).
  5. _build_feature_state_change_summary builds: scope = "Environment default", state = "enabled" (because fs.enabled is True), output: "Environment default: enabled".
  6. Audit log: New version published for feature: foo\n- Environment default: enabled.
  7. Now perform a different change on a fresh version: leave value at "v1" and toggle enabled from False to True. The audit log is identical: - Environment default: enabled.

Two semantically distinct changes (value edit vs. enabled toggle) produce the same audit-log line, and a reader cannot tell what actually changed — exactly the problem this PR was meant to fix (linked issue #7526: "opaque audit logs … completely omitting which feature states were actually changed").

Why existing code does not prevent it

Nothing in the new code path reads feature_state_value or compares against the previous version. The line template is f"{scope}: {state}" with state derived purely from the current fs.enabled. The test suite added in this PR only exercises (a) no changes, (b) an env-default toggle, (c) a brand-new segment override, (d) a brand-new identity override — none cover a value-only or MV-only change against a previous version.

Impact

Quality-of-message regression vs. the PR's stated goal. No crash, audit logs are still created, but they remain misleading/opaque for the very common case of users editing a feature state's value (e.g. config string, JSON payload) without toggling enabled. For multivariate features the line is similarly uninformative.

How to fix

In _build_feature_state_change_summary, accept (or look up) the previous feature state and emit what actually changed — e.g.:

  • Environment default: value changed ("v1" → "v2")
  • Environment default: enabled (was disabled)
  • Environment default: multivariate allocation changed

Or, less ambitiously, append the current value alongside the enabled flag so at least the value is visible (still not great for MV). Either way, add a regression test that mutates feature_state_value only and asserts the audit log differs from the equivalent enabled-toggle case.

@SahilJat
Copy link
Copy Markdown
Contributor Author

For the deleted states — I kept it to just the current version's feature states since the issue didn't explicitly mention deletions, but you're right that a removed segment override is still a change and should show up in the log.
Will fix both in the next commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit: enrich EnvironmentFeatureVersion-published log with changed feature states

2 participants