Skip to content

feat: add EvaluateFlagsAsync() API for single-call flag evaluation#187

Open
dmarticus wants to merge 6 commits intomainfrom
posthog-code/dotnet-evaluate-flags-api-v2
Open

feat: add EvaluateFlagsAsync() API for single-call flag evaluation#187
dmarticus wants to merge 6 commits intomainfrom
posthog-code/dotnet-evaluate-flags-api-v2

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Summary

  • New EvaluateFlagsAsync(distinctId, options) returns a FeatureFlagEvaluations snapshot — one /flags call powers all flag branching for the rest of the request.
  • The snapshot exposes IsEnabled / GetFlag / GetFlagPayload plus OnlyAccessed() and Only(...) filter helpers, with $feature_flag_called fired lazily on first access and deduped against the existing per-distinct-id cache.
  • New Capture(..., FeatureFlagEvaluations? flags, ...) overload (and matching CaptureException) attaches $feature/<key> and $active_feature_flags from the snapshot — no second /flags request.
  • Bug fix: locally-evaluated flags previously emitted locally_evaluated=false on $feature_flag_called. They now correctly carry locally_evaluated=true, $feature_flag_reason="Evaluated locally", and a new $feature_flag_definitions_loaded_at timestamp.
  • The single-flag API surface — IsFeatureEnabledAsync, GetFeatureFlagAsync, and the bool sendFeatureFlags overloads of Capture/CaptureException — is now [Obsolete(error: false)]. Users see a non-breaking migration warning on update.

RFC: PostHog/requests-for-comments-internal#1020 · Reference implementations: posthog-python#539, posthog-js#3476.

Design decisions

  • GetFlag returns the existing public FeatureFlag? (with implicit bool/string conversions) rather than mirroring Node's bare bool|string|null — keeps a single canonical .NET flag type and gives callers the payload too.
  • Capture(flags:) is a separate overload, not a new optional parameter on the existing one — adding a defaulted parameter to a positional public signature is source-breaking.
  • AllFeatureFlagsOptions.FlagKeysToEvaluate (already present) is reused as the request-body scoper; in-memory filtering uses the snapshot's own Only(keys) so the two concerns stay distinct.
  • The snapshot takes a narrow internal IFeatureFlagEvaluationsHost (fire-deduped-event + log-warning), not the full client — keeps the snapshot easy to test in isolation.
  • Filtered clones (OnlyAccessed/Only) get a fresh ConcurrentDictionary for accessed keys so child access doesn't back-propagate to the parent and the snapshot is safe to read across parallel branches.
  • OnlyAccessed() with no prior access logs a warning and returns all flags as a fallback (rather than silently dropping exposure data); silenceable via a new PostHogOptions.FeatureFlagsLogWarnings knob.
  • The new interface members are emitted as default interface methods on netstandard2.1+/net8 (delegating to the legacy overload) so external IPostHogClient implementers see an additive change. Netstandard2.0 keeps abstract members because the ns2.0 runtime can't host DIMs — .NET Framework 4.x consumers who hand-implement IPostHogClient (test fakes, custom decorators) will need to add the three new members on rebuild. The release notes should call this out.

Phase 2 (separate PR)

  • PostHog.AspNetCore's PostHogVariantFeatureManager still calls the deprecated GetFeatureFlagAsync (per-flag) — migrate to EvaluateFlagsAsync + per-request snapshot.
  • PostHog.AI's PostHogOpenAIHandler still calls the deprecated Capture(..., sendFeatureFlags: false, ...); switching to the snapshot overload also requires updating the NSubstitute test mocks.
  • Migrate Examples.cs and the samples/ projects to demonstrate the snapshot pattern in docs.

All UnitTests + UnitTests.AspNetCore + PostHog.AI.Tests pass; 18 new tests cover the snapshot, filter helpers, capture wiring, mixed local+remote merge, FlagMissing propagation, CaptureException(flags:), and cross-path dedup.

Originally opened as #186; force-pushing the rebase tripped the verified-signatures branch protection rule (an older unsigned auto-bot commit on main), so this is the same change on a fresh branch.


Created with PostHog Code

Adds a new EvaluateFlagsAsync(distinctId, options) method on the client
that returns a FeatureFlagEvaluations snapshot. The snapshot powers
IsEnabled / GetFlag / GetFlagPayload calls, fires $feature_flag_called
lazily (deduped against the existing per-distinct-id cache), and can be
forwarded to a new Capture(..., flags: snapshot) overload to attach
$feature/<key> and $active_feature_flags to events without a second
/flags request. Mirrors PostHog/posthog-js#3476 and PostHog/posthog-python#539.

Also fixes a long-standing bug where the legacy single-flag path
hard-coded locally_evaluated=false on every $feature_flag_called event.
Locally-evaluated flags now correctly carry locally_evaluated=true,
$feature_flag_reason="Evaluated locally", and a new
$feature_flag_definitions_loaded_at timestamp surfaced via
LocalFeatureFlagsLoader.

The existing IsFeatureEnabledAsync / GetFeatureFlagAsync /
Capture(..., sendFeatureFlags, ...) APIs are unchanged in this PR; a
follow-up minor will mark them deprecated in favor of the snapshot API.

Generated-By: PostHog Code
Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

posthog-dotnet Compliance Report

Date: 2026-04-29 22:16:01 UTC
Duration: 225ms

⚠️ Some Tests Failed

0/1 tests passed, 1 failed


Feature_Flags Tests

⚠️ 0/1 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 43ms

Failures

request_payload.request_with_person_properties_device_id

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:12
@dmarticus dmarticus requested review from a team and haacked as code owners April 27, 2026 22:12
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Comments Outside Diff (3)

  1. src/PostHog/PostHogClient.cs, line 963-967 (link)

    P2 Redundant Reason on locally-evaluated records (OnceAndOnlyOnce)

    ToRecord stores Reason = "Evaluated locally" for locally-evaluated flags, but EvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded gates the record.Reason branch with !record.LocallyEvaluated, so this value is never read back. The "Evaluated locally" string that actually appears in events is hardcoded inside BuildFeatureFlagCalledProperties. Setting reason to "Evaluated locally" in the record is therefore a superfluous part — setting it to null for locally-evaluated flags would produce identical runtime behaviour.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/PostHog/PostHogClient.cs
    Line: 963-967
    
    Comment:
    **Redundant `Reason` on locally-evaluated records (OnceAndOnlyOnce)**
    
    `ToRecord` stores `Reason = "Evaluated locally"` for locally-evaluated flags, but `EvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded` gates the `record.Reason` branch with `!record.LocallyEvaluated`, so this value is never read back. The `"Evaluated locally"` string that actually appears in events is hardcoded inside `BuildFeatureFlagCalledProperties`. Setting `reason` to `"Evaluated locally"` in the record is therefore a superfluous part — setting it to `null` for locally-evaluated flags would produce identical runtime behaviour.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs, line 1136-1148 (link)

    P2 Prefer parameterised tests

    IsEnabledReturnsFalseForUnknownKey and GetFlagReturnsNullForUnknownKey set up identical state and both assert the "unknown key" behaviour of different accessor methods. Per the project's conventions, these are candidates for a [Theory] / [InlineData] pair (e.g. parameterising on the accessor under test and the expected returned value), keeping the shared arrange logic in one place.

    Context Used: Do not attempt to comment on incorrect alphabetica... (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs
    Line: 1136-1148
    
    Comment:
    **Prefer parameterised tests**
    
    `IsEnabledReturnsFalseForUnknownKey` and `GetFlagReturnsNullForUnknownKey` set up identical state and both assert the "unknown key" behaviour of different accessor methods. Per the project's conventions, these are candidates for a `[Theory]` / `[InlineData]` pair (e.g. parameterising on the accessor under test and the expected returned value), keeping the shared arrange logic in one place.
    
    **Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  3. tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs, line 1355-1358 (link)

    P2 Test relies on dictionary iteration order

    "$active_feature_flags":[\"flag-a\",\"flag-c\"]" is asserted as a literal substring of the JSON body. AddFeatureFlagsToCapturedEvent builds the array by iterating flags.Records — a Dictionary<string, EvaluatedFlagRecord> — whose enumeration order is an implementation detail, not a language guarantee. In practice .NET preserves insertion order, but the assertion is fragile. A more robust check would deserialise the array from the body and use Assert.Equal(new[] { "flag-a", "flag-c" }, actual) (or Assert.Equivalent if order is intentionally unimportant).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs
    Line: 1355-1358
    
    Comment:
    **Test relies on dictionary iteration order**
    
    `"$active_feature_flags":[\"flag-a\",\"flag-c\"]"` is asserted as a literal substring of the JSON body. `AddFeatureFlagsToCapturedEvent` builds the array by iterating `flags.Records` — a `Dictionary<string, EvaluatedFlagRecord>` — whose enumeration order is an implementation detail, not a language guarantee. In practice .NET preserves insertion order, but the assertion is fragile. A more robust check would deserialise the array from the body and use `Assert.Equal(new[] { "flag-a", "flag-c" }, actual)` (or `Assert.Equivalent` if order is intentionally unimportant).
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/PostHog/Features/FeatureFlagEvaluations.cs
Line: 18

Comment:
**`_accessed` is not thread-safe**

`_accessed` is a plain `HashSet<string>` with no synchronization. `RecordAccess` calls `_accessed.Add()` without a lock, so if a caller shares a snapshot across threads — e.g., parallel `Task.WhenAll` branches each calling `IsEnabled()` on the same snapshot — the `HashSet` can be corrupted or throw. `FeatureFlagEvaluations` is a public type with no documented single-thread constraint.

Consider replacing `_accessed` with a `ConcurrentDictionary<string, byte>` (used as a set) or wrapping the mutation in a `lock`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/PostHog/PostHogClient.cs
Line: 963-967

Comment:
**Redundant `Reason` on locally-evaluated records (OnceAndOnlyOnce)**

`ToRecord` stores `Reason = "Evaluated locally"` for locally-evaluated flags, but `EvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded` gates the `record.Reason` branch with `!record.LocallyEvaluated`, so this value is never read back. The `"Evaluated locally"` string that actually appears in events is hardcoded inside `BuildFeatureFlagCalledProperties`. Setting `reason` to `"Evaluated locally"` in the record is therefore a superfluous part — setting it to `null` for locally-evaluated flags would produce identical runtime behaviour.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs
Line: 1136-1148

Comment:
**Prefer parameterised tests**

`IsEnabledReturnsFalseForUnknownKey` and `GetFlagReturnsNullForUnknownKey` set up identical state and both assert the "unknown key" behaviour of different accessor methods. Per the project's conventions, these are candidates for a `[Theory]` / `[InlineData]` pair (e.g. parameterising on the accessor under test and the expected returned value), keeping the shared arrange logic in one place.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs
Line: 1355-1358

Comment:
**Test relies on dictionary iteration order**

`"$active_feature_flags":[\"flag-a\",\"flag-c\"]"` is asserted as a literal substring of the JSON body. `AddFeatureFlagsToCapturedEvent` builds the array by iterating `flags.Records` — a `Dictionary<string, EvaluatedFlagRecord>` — whose enumeration order is an implementation detail, not a language guarantee. In practice .NET preserves insertion order, but the assertion is fragile. A more robust check would deserialise the array from the body and use `Assert.Equal(new[] { "flag-a", "flag-c" }, actual)` (or `Assert.Equivalent` if order is intentionally unimportant).

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat: add EvaluateFlagsAsync() API for s..." | Re-trigger Greptile

{
readonly IFeatureFlagEvaluationsHost _host;
readonly IReadOnlyDictionary<string, EvaluatedFlagRecord> _records;
readonly HashSet<string> _accessed;
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.

P1 _accessed is not thread-safe

_accessed is a plain HashSet<string> with no synchronization. RecordAccess calls _accessed.Add() without a lock, so if a caller shares a snapshot across threads — e.g., parallel Task.WhenAll branches each calling IsEnabled() on the same snapshot — the HashSet can be corrupted or throw. FeatureFlagEvaluations is a public type with no documented single-thread constraint.

Consider replacing _accessed with a ConcurrentDictionary<string, byte> (used as a set) or wrapping the mutation in a lock.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/PostHog/Features/FeatureFlagEvaluations.cs
Line: 18

Comment:
**`_accessed` is not thread-safe**

`_accessed` is a plain `HashSet<string>` with no synchronization. `RecordAccess` calls `_accessed.Add()` without a lock, so if a caller shares a snapshot across threads — e.g., parallel `Task.WhenAll` branches each calling `IsEnabled()` on the same snapshot — the `HashSet` can be corrupted or throw. `FeatureFlagEvaluations` is a public type with no documented single-thread constraint.

Consider replacing `_accessed` with a `ConcurrentDictionary<string, byte>` (used as a set) or wrapping the mutation in a `lock`.

How can I resolve this? If you propose a fix, please make it concise.

- FeatureFlagEvaluations._accessed: HashSet<string> -> ConcurrentDictionary<string, byte>
  so callers may share a snapshot across parallel branches without corrupting it.
- ToRecord: leave EvaluatedFlagRecord.Reason null for locally-evaluated flags;
  the "Evaluated locally" string is hardcoded inside BuildFeatureFlagCalledProperties
  and the host gates record.Reason with !LocallyEvaluated, so it was unread.
- Collapse IsEnabledReturnsFalseForUnknownKey + GetFlagReturnsNullForUnknownKey
  into a parameterized [Theory] over the accessor under test.
- Replace the brittle substring match on $active_feature_flags with a parsed,
  order-independent comparison; Dictionary iteration order isn't a guarantee.

Generated-By: PostHog Code
Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
Copy link
Copy Markdown
Collaborator

@haacked haacked left a comment

Choose a reason for hiding this comment

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

Love seeming my friends write C# code! There's a couple minor blocking issues and a few other suggestions to make this nice idiomatic and non-breaking C#.

/// <param name="flags">A snapshot of feature flag evaluations. When non-null, <c>$feature/&lt;key&gt;</c> and <c>$active_feature_flags</c> are attached from the snapshot — no <c>/flags</c> call is made.</param>
/// <param name="timestamp">Optional: Custom timestamp when the event occurred. If not provided, uses current time.</param>
/// <returns><c>true</c> if the event was successfully enqueued. Otherwise <c>false</c>.</returns>
bool Capture(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

blocking: Adding three abstract members to a public interface is a source-and-binary break for any external implementer (custom decorators, hand-rolled fakes). LangVersion 13.0 is set on this repo, so default interface methods are available. Default each new member to a sensible no-op and the change becomes additive.

Suggested change
bool Capture(
bool Capture(
string distinctId,
string eventName,
Dictionary<string, object>? properties,
GroupCollection? groups,
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp = null)
=> Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp);

Same shape on the CaptureException overload at line 155 (delegate to the sendFeatureFlags: false version) and on EvaluateFlagsAsync at line 231 (throw new NotSupportedException(...)). PostHogClient's explicit implementations remain and shadow the defaults; in-box behavior is unchanged.

Comment thread Directory.Build.props
<Project>
<PropertyGroup>
<Version>2.5.0</Version>
<Version>2.6.0</Version>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

blocking: If the default-interface-methods change on IPostHogClient lands, 2.6.0 is fine. Without it, this should be 3.0.0. Adding non-default abstract members to a shipped public interface is a SemVer major break, and pinning to [2.5.0,3.0.0) shouldn't silently pick up a compile-breaking change.

Suggested change
<Version>2.6.0</Version>
<Version>3.0.0</Version>

groups: null,
errors: null);

EvaluatedFlagRecord? RecordAccess(string key)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Every IsEnabled/GetFlag call hits _host.TryCaptureFeatureFlagCalledEventIfNeeded, which allocates a List<string>, a synthetic FeatureFlag (when record is null), a property Dictionary, and acquires the MemoryCache lock — even when the snapshot already saw this key. _accessed.TryAdd returns false on repeats; gate the host call on that. The MemoryCache still serves cross-snapshot dedup, so this is purely an in-snapshot fast path.

Suggested change
EvaluatedFlagRecord? RecordAccess(string key)
EvaluatedFlagRecord? RecordAccess(string key)
{
var keyChecked = NotNull(key);
var firstAccess = _accessed.TryAdd(keyChecked, 0);
_records.TryGetValue(keyChecked, out var record);
if (!firstAccess || string.IsNullOrEmpty(DistinctId))
{
// Already accessed in this snapshot, or empty-distinct-id safety fallback:
// skip the per-call dedup cache hit and property allocation.
return record;
}
_host.TryCaptureFeatureFlagCalledEventIfNeeded(
distinctId: DistinctId,
featureKey: keyChecked,
record: record,
groups: _groups,
requestId: RequestId,
evaluatedAt: EvaluatedAt,
flagDefinitionsLoadedAt: FlagDefinitionsLoadedAt,
errors: _errors);
return record;
}

For a request that calls IsEnabled 1000 times in a loop, this drops 1000 dictionary allocations and 1000 cache-lookup lock acquisitions to 1 of each.

Comment thread src/PostHog/PostHogClient.cs Outdated
locallyEvaluated: record?.LocallyEvaluated ?? false,
flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null);

// For locally-evaluated flags from a snapshot we still want id/version/reason metadata
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: For non-locally-evaluated flags, BuildFeatureFlagCalledProperties already writes $feature_flag_id/$feature_flag_version/$feature_flag_reason from the FeatureFlagWithMetadata branch. Then this block writes them again from record.Id/record.Version/record.Reason — same source (the record's Flag is the same FeatureFlagWithMetadata whose metadata seeded the record), so this is dead code. A reader has to trace both paths to convince themselves the values can't diverge.

If the intent is to handle a record that carries id/version without a FeatureFlagWithMetadata flag, make that intent explicit in a comment and have BuildFeatureFlagCalledProperties skip the metadata branch when called from this path. Otherwise drop lines 692–705.


public int? Id { get; init; }
public int? Version { get; init; }
public string? Reason { get; init; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Reason semantics are split across three locations: this field holds the remote reason; BuildFeatureFlagCalledProperties hardcodes "Evaluated locally" for the local path; EvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded then re-applies record.Reason only if (!record.LocallyEvaluated). Three places to keep in sync.

Cleaner: store the resolved reason on the record at construction time. In ToRecord, set reason = locallyEvaluated ? "Evaluated locally" : (flag as FeatureFlagWithMetadata)?.Reason. Drop the local-vs-remote branch in BuildFeatureFlagCalledProperties and the !record.LocallyEvaluated guard in EvaluationsHost. The legacy single-flag path doesn't have a record so it would still hardcode the string inline — already the case today. The record becomes self-describing.

If you keep the current shape, at minimum document the asymmetry on the field:

Suggested change
public string? Reason { get; init; }
/// <summary>
/// The evaluation reason from <see cref="FeatureFlagWithMetadata"/>. Intentionally <c>null</c>
/// for locally-evaluated flags — <c>BuildFeatureFlagCalledProperties</c> hardcodes
/// <c>"Evaluated locally"</c> for that path so the two reason sources cannot drift.
/// </summary>
public string? Reason { get; init; }

Comment thread src/PostHog/PostHogClient.cs Outdated
catch (ApiException e) when (e.ErrorType is "quota_limited")
{
_logger.LogWarningQuotaExceeded(e);
return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Local-pass quota_limited returns an empty snapshot here — discarding any flags evaluated up to this point and skipping the remote pass. But remote-pass quota_limited (around line 793–798) goes via flagsResult.QuotaLimited.Contains("feature_flags") → adds FeatureFlagError.QuotaLimited to the errors list and otherwise keeps the locally-evaluated records.

Local quota → empty snapshot, no error annotation. Remote quota → snapshot with whatever local produced, plus a $feature_flag_error: "quota_limited" annotation. The remote behavior is arguably the better one (preserves local work, surfaces the error). Is the discard on local-quota intentional?

/// Fires a <c>$feature_flag_called</c> event for the given access, deduplicated against the
/// per-distinct-id cache that the legacy single-flag path also writes to.
/// </summary>
void TryCaptureFeatureFlagCalledEventIfNeeded(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Did you consider just taking an internal PostHogClient reference on the snapshot directly? Only EvaluationsHost implements this interface, the interface is internal, and no test substitutes a fake host. With a direct reference, EvaluationsHost and this file go away (~30 lines).

Defensible argument exists for the seam — keeps the snapshot honest about what state it depends on, future tests stay cheap to mock — but "future tests might want a mock" is the textbook YAGNI smell.

If you keep it, the method name itself is overstuffed: Try, Capture, IfNeeded are three uncertainty modifiers, and the method has no return value to make Try meaningful.

Suggested change
void TryCaptureFeatureFlagCalledEventIfNeeded(
void CaptureFeatureFlagCalled(

/// <param name="flags">A snapshot of feature flag evaluations. When non-null, <c>$feature/&lt;key&gt;</c> and <c>$active_feature_flags</c> are attached from the snapshot — no <c>/flags</c> call is made.</param>
/// <param name="timestamp">Optional: Custom timestamp when the event occurred.</param>
/// <returns><c>true</c> if the exception event was successfully enqueued. Otherwise <c>false</c>.</returns>
bool CaptureException(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Consider landing [Obsolete(error: false)] on the legacy methods (IsFeatureEnabledAsync, GetFeatureFlagAsync, the bool sendFeatureFlags overload of Capture/CaptureException) in this PR rather than a follow-up. Non-breaking change, ~3 lines per method, lets users discover the migration path the moment they update the package.

[Obsolete("Prefer EvaluateFlagsAsync(...) which fetches all flags in one call. See PR #187.", error: false)]

The PR description mentions this is deferred to a follow-up; nothing about adding [Obsolete] blocks the rest of the deprecation cleanup that follow-up would do (README, examples, etc.).

/// </param>
/// <param name="cancellationToken">The cancellation token that can be used to cancel the operation.</param>
/// <returns>A snapshot of feature flag evaluations.</returns>
Task<FeatureFlagEvaluations> EvaluateFlagsAsync(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Worth adding a <remarks> paragraph cross-referencing Only(...) so users see the distinction at the entry point too: AllFeatureFlagsOptions.FlagKeysToEvaluate scopes the underlying /flags request body. FeatureFlagEvaluations.Only(keys) filters an already-evaluated snapshot in memory. Two helpers, two costs — easy to confuse.

var matches = System.Text.RegularExpressions.Regex.Matches(body, "\\$feature_flag_called");
Assert.Single(matches);
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

blocking: Three coverage gaps for new code paths:

  1. Mixed local+remote merge. OnlyEvaluateLocallyDoesNotHitRemote only exercises local. Nothing pins the merge in EvaluateFlagsAsync (PostHogClient.cs:800–806) where local fills some records and the remote pass fills the rest with locallyEvaluated: false. A regression that flips the if (!records.ContainsKey(key)) guard wouldn't be caught.

  2. Error propagation. flagsResult.ErrorsWhileComputingFlags adds FeatureFlagError.ErrorsWhileComputingFlags to the snapshot errors, and EvaluationsHost appends FeatureFlagError.FlagMissing when an unknown key is accessed. Neither path surfaces in any test, so the wiring through to $feature_flag_error on $feature_flag_called is unverified.

  3. CaptureException(..., flags: snapshot). Brand-new public overload, zero tests. A wiring mistake (e.g., accidentally passing flags: null in CaptureExceptionCore) would silently drop $feature/<key> props on exception events.

Skeleton for the merge test:

[Fact]
public async Task MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrectly()
{
    var container = new TestContainer(personalApiKey: "fake-personal-api-key");
    container.FakeHttpMessageHandler.AddLocalEvaluationResponse(
        """{"flags": [
            {"id": 1, "key": "local-only", "active": true, "rollout_percentage": 100,
             "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}},
            {"id": 2, "key": "needs-remote",
             "filters": {"groups": [{"properties": [{"key": "country", "value": "US"}],
                                     "rollout_percentage": 100}]}}
        ]}""");
    container.FakeHttpMessageHandler.AddFlagsResponse(
        """{"featureFlags": {"needs-remote": "variant-x", "remote-only": true}}""");
    var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
    var client = container.Activate<PostHogClient>();

    var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None);

    Assert.True(snapshot.IsEnabled("local-only"));
    Assert.Equal("variant-x", snapshot.GetFlag("needs-remote")?.VariantKey);
    Assert.True(snapshot.IsEnabled("remote-only"));

    await client.FlushAsync();
    using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false));
    var byFlag = doc.RootElement.GetProperty("batch").EnumerateArray()
        .ToDictionary(
            e => e.GetProperty("properties").GetProperty("$feature_flag").GetString()!,
            e => e.GetProperty("properties").GetProperty("locally_evaluated").GetBoolean());
    Assert.True(byFlag["local-only"]);
    Assert.False(byFlag["needs-remote"]);
    Assert.False(byFlag["remote-only"]);
}

Skeleton for FlagMissing propagation:

[Fact]
public async Task UnknownKeyAccessAppendsFlagMissingError()
{
    var container = new TestContainer();
    container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"known": true}}""");
    var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
    var client = container.Activate<PostHogClient>();

    var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None);
    snapshot.IsEnabled("does-not-exist");

    await client.FlushAsync();
    using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false));
    var props = doc.RootElement.GetProperty("batch").EnumerateArray().Single().GetProperty("properties");
    Assert.Contains("FlagMissing",
        props.GetProperty("$feature_flag_error").GetString(), StringComparison.Ordinal);
}

Skeleton for CaptureException:

[Fact]
public async Task CaptureExceptionAttachesFeatureFlagsFromSnapshot()
{
    var container = new TestContainer();
    container.FakeHttpMessageHandler.AddFlagsResponse(
        """{"featureFlags": {"flag-a": true, "flag-b": "variant-x"}}""");
    var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
    var client = container.Activate<PostHogClient>();

    var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None);
    client.CaptureException(new InvalidOperationException("boom"), "user-1",
        properties: null, groups: null, flags: snapshot);

    await client.FlushAsync();
    using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false));
    var exceptionEvent = doc.RootElement.GetProperty("batch").EnumerateArray()
        .Single(e => e.GetProperty("event").GetString() == "$exception");
    var props = exceptionEvent.GetProperty("properties");
    Assert.True(props.GetProperty("$feature/flag-a").GetBoolean());
    Assert.Equal("variant-x", props.GetProperty("$feature/flag-b").GetString());
}

Address PR feedback:

- IPostHogClient: add default interface implementations for the new Capture(flags:),
  CaptureException(flags:), and EvaluateFlagsAsync members so external implementers
  don't see a source break. Conditionally compiled — DIMs only on netstandard2.1+
  (the runtime requirement); netstandard2.0 keeps abstract members.
- FeatureFlagEvaluations.RecordAccess: early-return on repeat access, dropping
  per-call dedup-cache lookups + property allocation when a key has already been
  seen by this snapshot. Cross-snapshot dedup still flows through the MemoryCache.
- AddFeatureFlagsToCapturedEvent (snapshot path): single-pass enumeration over
  Records, skip the LINQ Where/Select/ToArray for $active_feature_flags.
- FeatureFlagEvaluations._records: tighten field type to Dictionary so Keys is a
  clean expression-bodied getter (no IReadOnlyDictionary cast).
- FeatureFlagEvaluations.Only(...): lazy missing-keys list — no allocation when
  every requested key is present.
- EvaluationsHost: drop the redundant id/version/reason copy block — the values
  it would write are already populated by BuildFeatureFlagCalledProperties via
  the FeatureFlagWithMetadata pattern match.
- EvaluatedFlagRecord: remove the now-unused Id/Version/Reason fields. The
  property dict is built from record.Flag (typed as FeatureFlagWithMetadata when
  present) rather than from duplicated record-level state.
- EvaluateFlagsAsync: local-pass quota_limited preserves locally-evaluated
  records and surfaces FeatureFlagError.QuotaLimited (matches remote-pass
  behavior); previously it discarded local results entirely. Add a comment on
  the local-wins merge clarifying the divergence from GetAllFeatureFlagsAsync.
- IPostHogClient.EvaluateFlagsAsync: <remarks> contrasting FlagKeysToEvaluate
  (request-body scoping) with FeatureFlagEvaluations.Only(...) (in-memory).
- IFeatureFlagEvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded ->
  CaptureFeatureFlagCalled (no return value, no try semantics).

Tests added:
- MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrectly: pins the
  local-wins merge with locally_evaluated tagged correctly per source.
- UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled: pins the
  $feature_flag_error wiring through to the emitted event.
- CaptureExceptionAttachesFeatureFlagsFromSnapshot: pins the new
  CaptureException(flags:) overload so a CaptureExceptionCore wiring mistake
  would be caught.

Generated-By: PostHog Code
Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new “single /flags call” feature-flag snapshot API (EvaluateFlagsAsync) and wires it through capture flows, while fixing locally-evaluated exposure event metadata.

Changes:

  • Introduces FeatureFlagEvaluations snapshot with lazy/deduped $feature_flag_called emission and filtering helpers (OnlyAccessed, Only).
  • Adds Capture(..., flags: FeatureFlagEvaluations) / CaptureException(..., flags: ...) overloads to attach $feature/<key> + $active_feature_flags without an extra /flags call.
  • Fixes $feature_flag_called properties for locally-evaluated flags (locally_evaluated=true, reason, and definitions-loaded timestamp) and adds warning-log controls.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/UnitTests/Features/FeatureFlagsTests.cs Updates assertions for corrected locally-evaluated $feature_flag_called properties.
tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs Adds unit tests for the new snapshot API, filtering helpers, dedup behavior, and capture wiring.
src/PostHog/PostHogClient.cs Implements EvaluateFlagsAsync, snapshot-host bridge, new capture overloads, and updated exposure event property building.
src/PostHog/IPostHogClient.cs Extends public interface with snapshot evaluation + snapshot-based capture overloads.
src/PostHog/Generated/VersionConstants.cs Bumps library version constant to 2.6.0.
src/PostHog/Features/LocalFeatureFlagsLoader.cs Tracks and exposes “flag definitions loaded at” timestamp for local evaluation.
src/PostHog/Features/IFeatureFlagEvaluationsHost.cs Introduces internal host seam for snapshot → client interactions (deduped capture + warnings).
src/PostHog/Features/FeatureFlagExtensions.cs Adds convenience extension overloads for EvaluateFlagsAsync.
src/PostHog/Features/FeatureFlagEvaluations.cs Adds the new snapshot type (access tracking, filtering, lazy exposure capture).
src/PostHog/Features/EvaluatedFlagRecord.cs Adds internal record type used by the snapshot for event/property attachment and exposure capture.
src/PostHog/Config/PostHogOptions.cs Adds FeatureFlagsLogWarnings option for snapshot filter-helper warnings.
Directory.Build.props Bumps package version to 2.6.0.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/PostHog/PostHogClient.cs Outdated
Comment on lines +805 to +809
catch (Exception e) when (e is not ArgumentException and not NullReferenceException)
{
_logger.LogErrorUnableToGetFeatureFlagsAndPayloads(e);
errors.Add(FeatureFlagError.UnknownError);
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This catch-all will also swallow OperationCanceledException/TaskCanceledException when the provided CancellationToken is canceled, causing EvaluateFlagsAsync to ignore cancellation and instead log/return an UnknownError snapshot. Exclude OperationCanceledException (and/or TaskCanceledException) in the filter so cancellation propagates like GetFeatureFlagAsync does.

Copilot uses AI. Check for mistakes.
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Microsoft.Extensions.Options is not used in this file. With TreatWarningsAsErrors enabled, the resulting unused-using warning (CS8019) will fail the build; remove the unused using directive.

Suggested change
using Microsoft.Extensions.Options;

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/PostHogClient.cs Outdated
GroupCollection? groups,
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp = null)
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags, timestamp);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This call mixes a named argument (sendFeatureFlags: false) with subsequent positional arguments (flags, timestamp), which is a C# compile-time error. Use either all positional arguments or name the remaining arguments (e.g., flags: flags, timestamp: timestamp).

Suggested change
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags, timestamp);
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags: flags, timestamp: timestamp);

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/PostHogClient.cs Outdated
GroupCollection? groups,
bool sendFeatureFlags,
DateTimeOffset? timestamp = null)
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This call passes a named argument (flags: null) followed by a positional argument (timestamp), which is not allowed in C# and will not compile. Name the timestamp argument as well (timestamp: timestamp) or make the entire call positional.

Suggested change
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp);
=> CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp: timestamp);

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/PostHogClient.cs Outdated
GroupCollection? groups,
bool sendFeatureFlags,
DateTimeOffset? timestamp = null)
=> CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags, flags: null, timestamp);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This call mixes positional arguments with a later named argument (flags: null) and then a positional argument (timestamp), which is a compile-time error. Use all positional arguments or name all arguments after the first named one (e.g., flags: null, timestamp: timestamp).

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/PostHogClient.cs Outdated
GroupCollection? groups,
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp = null)
=> CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags, timestamp);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This call mixes a named argument (sendFeatureFlags: false) with subsequent positional arguments (flags, timestamp), which is a C# compile-time error. Use all positional arguments or name the remaining arguments (flags: flags, timestamp: timestamp).

Suggested change
=> CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags, timestamp);
=> CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags: flags, timestamp: timestamp);

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/IPostHogClient.cs Outdated
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp = null)
#if !NETSTANDARD2_0
=> Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Default interface implementation: the call uses a named argument (sendFeatureFlags: false) followed by a positional argument (timestamp), which will not compile. Name the timestamp argument too (timestamp: timestamp) or use all positional arguments.

Suggested change
=> Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp)
=> Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp: timestamp)

Copilot uses AI. Check for mistakes.
Comment thread src/PostHog/IPostHogClient.cs Outdated
Comment on lines +167 to +168
=> CaptureException(exception, distinctId, properties, groups, sendFeatureFlags: false, timestamp)
#endif
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Default interface implementation: the call uses a named argument (sendFeatureFlags: false) followed by a positional argument (timestamp), which is a compile-time error. Use timestamp: timestamp (or make the whole invocation positional).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@haacked haacked left a comment

Choose a reason for hiding this comment

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

Nice! We'll make a .NET developer out of you!

Just some nits/suggestions. But nothing blocking.

GroupCollection? groups,
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp = null)
#if !NETSTANDARD2_0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Worth flagging in the release notes that the DIM fix is partial. The #if !NETSTANDARD2_0 guard wraps the default body but not the member itself, so on the netstandard2.0 build the new methods are still abstract. A .NET Framework consumer who hand-implements IPostHogClient (test fakes, decorators) will hit CS0535 on rebuild. DIMs need IL features netstandard2.0 doesn't have, so the conditional is unavoidable here.

Given .NET Framework is the least supported audience, keeping 2.6.0 and calling this out in the release notes seems right. Something like:

⚠️ Breaking for netstandard2.0 implementers. Three new methods on IPostHogClient (Capture(flags:), CaptureException(flags:), EvaluateFlagsAsync) ship as abstract on the netstandard2.0 build because default interface methods aren't supported on that runtime. .NET Framework consumers who hand-implement IPostHogClient will need to add stubs (or upgrade to netstandard2.1+/net8.0 builds, where the members are default-implemented). netstandard2.1 and net8.0 consumers see this as an additive change.

/// The internal per-flag records. Used by <see cref="PostHogClient"/>'s capture path to attach
/// <c>$feature/&lt;key&gt;</c> properties.
/// </summary>
internal Dictionary<string, EvaluatedFlagRecord> Records => _records;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: Tightening the field to Dictionary<> was the right call for the Keys getter, but Records still hands the inner dictionary out by reference. The one call site (PostHogClient.AddFeatureFlagsToCapturedEvent) only iterates, so dropping to IReadOnlyDictionary<> here costs nothing and prevents a future flags.Records.Add(...) from silently corrupting the snapshot.

Suggested change
internal Dictionary<string, EvaluatedFlagRecord> Records => _records;
internal IReadOnlyDictionary<string, EvaluatedFlagRecord> Records => _records;

Comment thread src/PostHog/PostHogClient.cs Outdated
}
catch (ApiException e) when (e.ErrorType is "quota_limited")
{
// Quota-limited at the local-evaluation endpoint: keep whatever local records we
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: "keep whatever local records we computed before the failure" reads as if a partial set could be preserved, but quota_limited is only ever thrown by _featureFlagsLoader.GetFeatureFlagsForLocalEvaluationAsync, the first call in the try, before any evaluation runs. So records is always empty when this catch fires. Worth saying what the code actually does:

Suggested change
// Quota-limited at the local-evaluation endpoint: keep whatever local records we
// Quota-limited at /flags/definitions: no local records were produced (the failure
// happens during the definitions fetch, before evaluation), so the snapshot ends
// up empty. Still surface the error on $feature_flag_called and skip the remote
// pass to avoid double-charging quota.

Mark the four legacy paths replaced by EvaluateFlagsAsync + snapshot
[Obsolete(error: false)] so users see migration guidance the moment they
update the package:

- IPostHogClient.IsFeatureEnabledAsync / .GetFeatureFlagAsync
- IPostHogClient.Capture(..., bool sendFeatureFlags, ...)
- IPostHogClient.CaptureException(..., bool sendFeatureFlags, ...)

Cascade [Obsolete] to wrapper extensions:

- FeatureFlagExtensions: 5 IsFeatureEnabledAsync + 5 GetFeatureFlagAsync overloads
- CaptureExtensions: bool-sendFeatureFlags overloads of Capture / CapturePageView /
  CaptureScreenView
- CaptureExceptionExtensions: bool-sendFeatureFlags overloads

Each extension delegates internally; suppress CS0618 inside the body so the
warning surfaces at the user call site, not at the SDK call into itself.

Internal call sites that always passed sendFeatureFlags: false migrate to the
new Capture(..., flags: null, ...) overload — no behavioral change, but stops
the SDK from internally calling its own deprecated path.

Tests and samples that intentionally exercise the deprecated surface get a
file-level #pragma warning disable CS0618. The new
FeatureFlagEvaluationsTests cross-path dedup test wraps a single
IsFeatureEnabledAsync call in a per-call pragma so the rest of the file still
catches accidental new uses.

PostHog.AI's OpenAI handler keeps the legacy Capture(..., sendFeatureFlags:
false, ...) call with a #pragma + TODO; its tests assert the legacy mock
shape and migrating them is its own change.

PostHog.AspNetCore's PostHogVariantFeatureManager suppresses with a #pragma +
TODO; the FeatureManager API is per-flag so a snapshot rewrite is non-trivial.

All 781 unit tests, 26 AspNetCore tests, and 19 AI tests pass.

Generated-By: PostHog Code
Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
- sdk_compliance_adapter Program.cs: switch the lone Capture(..., sendFeatureFlags: false, ...)
  call to the new flags: null overload so the Docker publish in the
  SDK-compliance CI job no longer hits CS0618 → exit 1.
- EvaluateFlagsAsync: exclude OperationCanceledException from the catch-all so
  cancellation propagates instead of being logged as UnknownError. Matches
  GetFeatureFlagAsync's filter.
- FeatureFlagEvaluations.Records: typed as IReadOnlyDictionary so the one
  consumer (PostHogClient.AddFeatureFlagsToCapturedEvent) can iterate but
  cannot mutate the snapshot's underlying state.
- Local-quota comment in EvaluateFlagsAsync: clarify that `records` is always
  empty when the catch fires (the throwing call is the first inside the try).
- Capture / CaptureException / DIM bodies: name every trailing argument
  (timestamp:, flags:) so non-trailing-named-argument call sites don't
  trip future IDE/bot warnings even though the C# 7.2+ rules accept them.
- FeatureFlagEvaluationsTests: drop the unused
  Microsoft.Extensions.Options using directive.

Generated-By: PostHog Code
Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
Generated-By: PostHog Code

Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
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.

3 participants