Skip to content

Commit ddbb81f

Browse files
feat: Add Identify hooks to Client SDK (#234)
**Requirements** Adds BeforeIdentify / AfterIdentify hook stages to the existing hooks infrastructure, mirroring the evaluation series pattern. Hooks execute in forward order for BeforeIdentify and reverse (LIFO) order for AfterIdentify. Wraps LdClient.IdentifyAsync with the identify hook series so that configured hooks receive callbacks before and after each identify operation, with IdentifySeriesResult.Completed on success and IdentifySeriesResult.Error on failure. Threads maxWaitTime from Identify / IdentifyAsync into IdentifySeriesContext.Timeout so hooks have visibility into the caller's timeout configuration. - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions <img width="593" height="801" alt="image" src="https://github.com/user-attachments/assets/97cc5062-8e50-4eb5-8c2c-ec75e874d6b6" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes `LdClient` initialization and `Identify`/`IdentifyAsync` flow to run new hook callbacks and to always emit an identify series result, which could affect timing/ordering and error propagation around context changes. > > **Overview** > Adds an **identify hook series** to the SDK hook infrastructure, introducing `Hook.BeforeIdentify`/`Hook.AfterIdentify` plus new `IdentifySeriesContext` (includes timeout) and `IdentifySeriesResult` (Completed/Error). > > Extends the internal hook executor (`IHookExecutor`, `Executor`, `NoopExecutor`) with an async `IdentifySeries` pipeline that runs hooks in forward order before identify and reverse order after, logging and isolating hook-stage failures while still propagating identify exceptions. > > Updates `LdClient` to route both startup identify event recording and `Identify`/`IdentifyAsync` through the new identify series (including threading `maxWaitTime` into hook context) and adds unit tests covering ordering, data passing, logging on hook errors, result status, and activation during init. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b881e20. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e86f8be commit ddbb81f

File tree

12 files changed

+614
-52
lines changed

12 files changed

+614
-52
lines changed

pkgs/sdk/client/src/Hooks/Hook.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ public virtual SeriesData BeforeEvaluation(EvaluationSeriesContext context, Seri
7474
public virtual SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data,
7575
EvaluationDetail<LdValue> detail) => data;
7676

77+
78+
/// <summary>
79+
/// BeforeIdentify is executed by the SDK before an identify operation.
80+
///
81+
/// The modified data is not shared with any other hook. It will be passed to subsequent stages in the identify
82+
/// series, including <see cref="AfterIdentify"/>.
83+
///
84+
/// </summary>
85+
/// <param name="context">parameters associated with this identify operation</param>
86+
/// <param name="data">user-configurable data, currently empty</param>
87+
/// <returns>user-configurable data, which will be forwarded to <see cref="AfterIdentify"/></returns>
88+
public virtual SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) =>
89+
data;
90+
91+
92+
/// <summary>
93+
/// AfterIdentify is executed by the SDK after an identify operation.
94+
///
95+
/// The function should return the given <see cref="SeriesData"/> unmodified, for forward compatibility with subsequent
96+
/// stages that may be added.
97+
///
98+
/// </summary>
99+
/// <param name="context">parameters associated with this identify operation</param>
100+
/// <param name="data">user-configurable data from the <see cref="BeforeIdentify"/> stage</param>
101+
/// <param name="result">the result of the identify operation</param>
102+
/// <returns>user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK</returns>
103+
public virtual SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data,
104+
IdentifySeriesResult result) => data;
105+
77106
/// <summary>
78107
/// Constructs a new Hook with the given name. The name may be used in log messages.
79108
/// </summary>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
3+
namespace LaunchDarkly.Sdk.Client.Hooks
4+
{
5+
/// <summary>
6+
/// IdentifySeriesContext represents parameters associated with an identify operation. It is
7+
/// made available in <see cref="Hook"/> stage callbacks.
8+
/// </summary>
9+
public sealed class IdentifySeriesContext
10+
{
11+
/// <summary>
12+
/// The Context being identified.
13+
/// </summary>
14+
public Context Context { get; }
15+
16+
/// <summary>
17+
/// The timeout for the identify operation. A value of <see cref="TimeSpan.Zero"/> indicates
18+
/// that no timeout was specified.
19+
/// </summary>
20+
public TimeSpan Timeout { get; }
21+
22+
/// <summary>
23+
/// Constructs a new IdentifySeriesContext.
24+
/// </summary>
25+
/// <param name="context">the context being identified</param>
26+
/// <param name="timeout">the timeout for the identify operation</param>
27+
public IdentifySeriesContext(Context context, TimeSpan timeout)
28+
{
29+
Context = context;
30+
Timeout = timeout;
31+
}
32+
}
33+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace LaunchDarkly.Sdk.Client.Hooks
2+
{
3+
/// <summary>
4+
/// IdentifySeriesResult contains the outcome of an identify operation.
5+
/// </summary>
6+
public sealed class IdentifySeriesResult
7+
{
8+
/// <summary>
9+
/// Represents the possible statuses of an identify operation.
10+
/// </summary>
11+
public enum IdentifySeriesStatus
12+
{
13+
/// <summary>
14+
/// The identify operation completed successfully.
15+
/// </summary>
16+
Completed,
17+
18+
/// <summary>
19+
/// The identify operation encountered an error.
20+
/// </summary>
21+
Error
22+
}
23+
24+
/// <summary>
25+
/// The status of the identify operation.
26+
/// </summary>
27+
public IdentifySeriesStatus Status { get; }
28+
29+
/// <summary>
30+
/// Constructs a new IdentifySeriesResult.
31+
/// </summary>
32+
/// <param name="status">the status of the identify operation</param>
33+
public IdentifySeriesResult(IdentifySeriesStatus status)
34+
{
35+
Status = status;
36+
}
37+
}
38+
}

pkgs/sdk/client/src/ILdClientExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Threading.Tasks;
33
using LaunchDarkly.Sdk.Client.Interfaces;
44

pkgs/sdk/client/src/Interfaces/IFlagTracker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22

33
namespace LaunchDarkly.Sdk.Client.Interfaces
44
{

pkgs/sdk/client/src/Interfaces/ILdClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Threading.Tasks;
44
using LaunchDarkly.Sdk.Client.Integrations;

pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading.Tasks;
45
using LaunchDarkly.Logging;
56
using LaunchDarkly.Sdk.Client.Hooks;
67
using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces;
@@ -16,12 +17,17 @@ internal sealed class Executor : IHookExecutor
1617
private readonly IStageExecutor<EvaluationSeriesContext> _beforeEvaluation;
1718
private readonly IStageExecutor<EvaluationSeriesContext, EvaluationDetail<LdValue>> _afterEvaluation;
1819

20+
private readonly IStageExecutor<IdentifySeriesContext> _beforeIdentify;
21+
private readonly IStageExecutor<IdentifySeriesContext, IdentifySeriesResult> _afterIdentify;
22+
1923
public Executor(Logger logger, IEnumerable<Hook> hooks)
2024
{
2125
_logger = logger;
2226
_hooks = hooks.ToList();
2327
_beforeEvaluation = new BeforeEvaluation(logger, _hooks, EvaluationStage.Order.Forward);
2428
_afterEvaluation = new AfterEvaluation(logger, _hooks, EvaluationStage.Order.Reverse);
29+
_beforeIdentify = new BeforeIdentify(logger, _hooks, EvaluationStage.Order.Forward);
30+
_afterIdentify = new AfterIdentify(logger, _hooks, EvaluationStage.Order.Reverse);
2531
}
2632

2733
public EvaluationDetail<T> EvaluationSeries<T>(EvaluationSeriesContext context,
@@ -38,6 +44,31 @@ public EvaluationDetail<T> EvaluationSeries<T>(EvaluationSeriesContext context,
3844
return detail;
3945
}
4046

47+
public async Task<bool> IdentifySeries(Context context, TimeSpan maxWaitTime, Func<Task<bool>> identify)
48+
{
49+
var identifyContext = new IdentifySeriesContext(context, maxWaitTime);
50+
var seriesData = _beforeIdentify.Execute(identifyContext, default);
51+
52+
try
53+
{
54+
var result = await identify();
55+
56+
_afterIdentify.Execute(identifyContext,
57+
new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Completed),
58+
seriesData);
59+
60+
return result;
61+
}
62+
catch (Exception)
63+
{
64+
_afterIdentify.Execute(identifyContext,
65+
new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Error),
66+
seriesData);
67+
68+
throw;
69+
}
70+
}
71+
4172
public void Dispose()
4273
{
4374
foreach (var hook in _hooks)

pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Threading.Tasks;
23
using LaunchDarkly.Sdk.Client.Hooks;
34
using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces;
45

@@ -13,6 +14,9 @@ internal sealed class NoopExecutor : IHookExecutor
1314
public EvaluationDetail<T> EvaluationSeries<T>(EvaluationSeriesContext context,
1415
LdValue.Converter<T> converter, Func<EvaluationDetail<T>> evaluate) => evaluate();
1516

17+
public Task<bool> IdentifySeries(Context context, TimeSpan maxWaitTime, Func<Task<bool>> identify) =>
18+
identify();
19+
1620
public void Dispose()
1721
{
1822
}

pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Threading.Tasks;
23
using LaunchDarkly.Sdk.Client.Hooks;
34

45
namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces
@@ -12,7 +13,8 @@ namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces
1213
internal interface IHookExecutor : IDisposable
1314
{
1415
/// <summary>
15-
/// EvaluationSeries should run the evaluation series for each configured hook.
16+
/// Runs the evaluation series for each configured hook, invoking the <paramref name="evaluate"/>
17+
/// delegate to obtain the flag value. Exceptions thrown by the delegate are propagated to the caller.
1618
/// </summary>
1719
/// <param name="context">context for the evaluation series</param>
1820
/// <param name="converter">used to convert the primitive evaluation value into a wrapped <see cref="LdValue"/> suitable for use in hooks</param>
@@ -21,5 +23,16 @@ internal interface IHookExecutor : IDisposable
2123
/// <returns>the EvaluationDetail returned from the evaluator</returns>
2224
EvaluationDetail<T> EvaluationSeries<T>(EvaluationSeriesContext context,
2325
LdValue.Converter<T> converter, Func<EvaluationDetail<T>> evaluate);
26+
27+
/// <summary>
28+
/// Runs the identify series for each configured hook, invoking the <paramref name="identify"/>
29+
/// delegate to perform the identify operation. Exceptions thrown by the delegate are propagated
30+
/// to the caller.
31+
/// </summary>
32+
/// <param name="context">the evaluation context being identified</param>
33+
/// <param name="maxWaitTime">the timeout for the identify operation</param>
34+
/// <param name="identify">async function that performs the identify operation</param>
35+
/// <returns>the result of the identify operation</returns>
36+
Task<bool> IdentifySeries(Context context, TimeSpan maxWaitTime, Func<Task<bool>> identify);
2437
}
2538
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using System.Linq;
5+
using LaunchDarkly.Logging;
6+
using LaunchDarkly.Sdk.Client.Hooks;
7+
using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces;
8+
9+
namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Series
10+
{
11+
using SeriesData = ImmutableDictionary<string, object>;
12+
13+
internal class IdentifyStage
14+
{
15+
protected enum Stage
16+
{
17+
BeforeIdentify,
18+
AfterIdentify
19+
}
20+
21+
protected readonly EvaluationStage.Order _order;
22+
private readonly Logger _logger;
23+
24+
protected IdentifyStage(Logger logger, EvaluationStage.Order order)
25+
{
26+
_logger = logger;
27+
_order = order;
28+
}
29+
30+
protected void LogFailure(IdentifySeriesContext context, Hook h, Stage stage, Exception e)
31+
{
32+
_logger.Error("During identify of context \"{0}\", stage \"{1}\" of hook \"{2}\" reported error: {3}",
33+
context.Context.Key, stage.ToString(), h.Metadata.Name, e.Message);
34+
}
35+
}
36+
37+
internal sealed class BeforeIdentify : IdentifyStage, IStageExecutor<IdentifySeriesContext>
38+
{
39+
private readonly IEnumerable<Hook> _hooks;
40+
41+
public BeforeIdentify(Logger logger, IEnumerable<Hook> hooks, EvaluationStage.Order order) : base(logger, order)
42+
{
43+
_hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse();
44+
}
45+
46+
public IEnumerable<SeriesData> Execute(IdentifySeriesContext context, IEnumerable<SeriesData> _)
47+
{
48+
return _hooks.Select(hook =>
49+
{
50+
try
51+
{
52+
return hook.BeforeIdentify(context, SeriesData.Empty);
53+
}
54+
catch (Exception e)
55+
{
56+
LogFailure(context, hook, Stage.BeforeIdentify, e);
57+
return SeriesData.Empty;
58+
}
59+
}).ToList();
60+
}
61+
}
62+
63+
internal sealed class AfterIdentify : IdentifyStage, IStageExecutor<IdentifySeriesContext, IdentifySeriesResult>
64+
{
65+
private readonly IEnumerable<Hook> _hooks;
66+
67+
public AfterIdentify(Logger logger, IEnumerable<Hook> hooks, EvaluationStage.Order order) : base(logger, order)
68+
{
69+
_hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse();
70+
}
71+
72+
public IEnumerable<SeriesData> Execute(IdentifySeriesContext context, IdentifySeriesResult result,
73+
IEnumerable<SeriesData> seriesData)
74+
{
75+
return _hooks.Zip((_order == EvaluationStage.Order.Reverse ? seriesData.Reverse() : seriesData), (hook, data) =>
76+
{
77+
try
78+
{
79+
return hook.AfterIdentify(context, data, result);
80+
}
81+
catch (Exception e)
82+
{
83+
LogFailure(context, hook, Stage.AfterIdentify, e);
84+
return SeriesData.Empty;
85+
}
86+
}).ToList();
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)