Repository: open-feature/js-sdk
Pull request #1087 (2024-11-21 17:14:29)

feat: add evaluation details to finally hook
## This PR

- adds evaluation details to the `finally` stage in hooks.

### Notes

This breaks the signature of the `finally` stages based on [this spec enhancement](https://github.com/open-feature/spec/pull/280). It is **not** considered a breaking change to the SDK because hooks are marked as experimental in the spec, and the change has no impact on known hooks.

The noteworthy change to the interface is:

```diff
- finally?(hookContext: Readonly<HookContext<T>>, hookHints?: HookHints): HooksReturn;
+ finally?(hookContext: Readonly<HookContext<T>>, evaluationDetails: EvaluationDetails<T>, hookHints?: HookHints): HooksReturn;
```

### Follow-up Tasks

- Update the JS contribs repo



Patch:
diff --git a/packages/react/src/common/options.ts b/packages/react/src/common/options.ts
deleted file mode 100644
index 6f3120c4..00000000
--- a/packages/react/src/common/options.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { FlagEvaluationOptions } from '@openfeature/web-sdk';
-
-export type ReactFlagEvaluationOptions = ({
-  /**
-   * Enable or disable all suspense functionality.
-   * Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
-   * @experimental Suspense is an experimental feature subject to change in future versions.
-   */
-  suspend?: boolean;
-  suspendUntilReady?: never;
-  suspendWhileReconciling?: never;
-} | {
-  /**
-   * Suspend flag evaluations while the provider is not ready.
-   * Set to false if you don't want to show suspense fallbacks until the provider is initialized.
-   * Defaults to false.
-   * Cannot be used in conjunction with `suspend` option.
-   * @experimental Suspense is an experimental feature subject to change in future versions.
-   */
-  suspendUntilReady?: boolean;
-  /**
-   * Suspend flag evaluations while the provider's context is being reconciled.
-   * Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
-   * Defaults to false.
-   * Cannot be used in conjunction with `suspend` option.
-   * @experimental Suspense is an experimental feature subject to change in future versions.
-   */
-  suspendWhileReconciling?: boolean;
-  suspend?: never;
-}) & {
-  /**
-   * Update the component if the provider emits a ConfigurationChanged event.
-   * Set to false to prevent components from re-rendering when flag value changes
-   * are received by the associated provider.
-   * Defaults to true.
-   */
-  updateOnConfigurationChanged?: boolean;
-  /**
-   * Update the component when the OpenFeature context changes.
-   * Set to false to prevent components from re-rendering when attributes which
-   * may be factors in flag evaluation change.
-   * Defaults to true.
-   */
-  updateOnContextChanged?: boolean;
-} & FlagEvaluationOptions;
-
-export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;
-
-/**
- * Default options.
- * DO NOT EXPORT PUBLICLY
- * @internal
- */
-export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
-  updateOnContextChanged: true,
-  updateOnConfigurationChanged: true,
-  suspendUntilReady: false,
-  suspendWhileReconciling: false,
-};
-
-/**
- * Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
- * DO NOT EXPORT PUBLICLY
- * @internal
- * @param {ReactFlagEvaluationOptions} options  options to normalize
- * @returns {NormalizedOptions} normalized options
- */
-export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (options: ReactFlagEvaluationOptions = {}) => {
-  const updateOnContextChanged = options.updateOnContextChanged;
-  const updateOnConfigurationChanged = options.updateOnConfigurationChanged;
-
-  // fall-back the suspense options to the catch-all `suspend` property 
-  const suspendUntilReady = 'suspendUntilReady' in options ? options.suspendUntilReady : options.suspend;
-  const suspendWhileReconciling = 'suspendWhileReconciling' in options ? options.suspendWhileReconciling : options.suspend;
-
-  return {
-    // only return these if properly set (no undefined to allow overriding with spread)
-    ...(typeof suspendUntilReady === 'boolean' && {suspendUntilReady}),
-    ...(typeof suspendWhileReconciling === 'boolean' && {suspendWhileReconciling}),
-    ...(typeof updateOnContextChanged === 'boolean' && {updateOnContextChanged}),
-    ...(typeof updateOnConfigurationChanged === 'boolean' && {updateOnConfigurationChanged}),
-  };
-};
diff --git a/packages/react/src/context/use-context-mutator.ts b/packages/react/src/context/use-context-mutator.ts
index 2667f186..800688ba 100644
--- a/packages/react/src/context/use-context-mutator.ts
+++ b/packages/react/src/context/use-context-mutator.ts
@@ -1,7 +1,7 @@
 import { useCallback, useContext, useRef } from 'react';
 import type { EvaluationContext } from '@openfeature/web-sdk';
 import { OpenFeature } from '@openfeature/web-sdk';
-import { Context } from '../common';
+import { Context } from '../internal';
 
 export type ContextMutationOptions = {
   /**
diff --git a/packages/react/src/evaluation/use-feature-flag.ts b/packages/react/src/evaluation/use-feature-flag.ts
index 673e84f3..899c042c 100644
--- a/packages/react/src/evaluation/use-feature-flag.ts
+++ b/packages/react/src/evaluation/use-feature-flag.ts
@@ -11,8 +11,8 @@ import {
   ProviderStatus,
 } from '@openfeature/web-sdk';
 import { useEffect, useRef, useState } from 'react';
-import type { ReactFlagEvaluationOptions} from '../common';
-import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../common';
+import type { ReactFlagEvaluationNoSuspenseOptions, ReactFlagEvaluationOptions } from '../options';
+import { DEFAULT_OPTIONS, isEqual, normalizeOptions, suspendUntilReady, useProviderOptions } from '../internal';
 import { useOpenFeatureClient } from '../provider/use-open-feature-client';
 import { useOpenFeatureClientStatus } from '../provider/use-open-feature-client-status';
 import type { FlagQuery } from '../query';
@@ -33,9 +33,6 @@ type ConstrainedFlagQuery<T> = FlagQuery<
           : JsonValue
 >;
 
-// suspense options removed for the useSuspenseFlag hooks
-type NoSuspenseOptions = Omit<ReactFlagEvaluationOptions, 'suspend' | 'suspendUntilReady' | 'suspendWhileReconciling'>;
-
 /**
  * Evaluates a feature flag generically, returning an react-flavored queryable object.
  * The resolver method to use is based on the type of the defaultValue.
@@ -84,13 +81,13 @@ type UseFlagReturn<T extends FlagValue> = ReturnType<typeof useFlag<T>>;
  * @param {string} flagKey the flag identifier
  * @template {FlagValue} T A optional generic argument constraining the default.
  * @param {T} defaultValue the default value; used to determine what resolved type should be used.
- * @param {NoSuspenseOptions} options for this evaluation
+ * @param {ReactFlagEvaluationNoSuspenseOptions} options for this evaluation
  * @returns { UseFlagReturn<T> } a queryable object containing useful information about the flag.
  */
 export function useSuspenseFlag<T extends FlagValue = FlagValue>(
   flagKey: string,
   defaultValue: T,
-  options?: NoSuspenseOptions,
+  options?: ReactFlagEvaluationNoSuspenseOptions,
 ): UseFlagReturn<T> {
   return useFlag(flagKey, defaultValue, { ...options, suspendUntilReady: true, suspendWhileReconciling: true });
 }
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index e2f902c5..e08a7ae6 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -3,5 +3,6 @@ export * from './query';
 export * from './provider';
 export * from './context';
 export * from './tracking';
+export * from './options';
 // re-export the web-sdk so consumers can access that API from the react-sdk
 export * from '@openfeature/web-sdk';
diff --git a/packages/react/src/common/context.ts b/packages/react/src/internal/context.ts
similarity index 68%
rename from packages/react/src/common/context.ts
rename to packages/react/src/internal/context.ts
index 9e44801e..4b495bac 100644
--- a/packages/react/src/common/context.ts
+++ b/packages/react/src/internal/context.ts
@@ -1,14 +1,16 @@
 import type { Client } from '@openfeature/web-sdk';
 import React from 'react';
-import type { NormalizedOptions, ReactFlagEvaluationOptions} from '../common';
-import { normalizeOptions } from '../common';
+import type { NormalizedOptions, ReactFlagEvaluationOptions } from '../options';
+import { normalizeOptions } from '.';
 
 /**
  * The underlying React context.
  * DO NOT EXPORT PUBLICLY
  * @internal
  */
-export const Context = React.createContext<{ client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined>(undefined);
+export const Context = React.createContext<
+  { client: Client; domain?: string; options: ReactFlagEvaluationOptions } | undefined
+>(undefined);
 
 /**
  * Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}.
diff --git a/packages/react/src/common/index.ts b/packages/react/src/internal/index.ts
similarity index 100%
rename from packages/react/src/common/index.ts
rename to packages/react/src/internal/index.ts
diff --git a/packages/react/src/common/is-equal.ts b/packages/react/src/internal/is-equal.ts
similarity index 100%
rename from packages/react/src/common/is-equal.ts
rename to packages/react/src/internal/is-equal.ts
diff --git a/packages/react/src/internal/options.ts b/packages/react/src/internal/options.ts
new file mode 100644
index 00000000..2f5fe3e6
--- /dev/null
+++ b/packages/react/src/internal/options.ts
@@ -0,0 +1,40 @@
+import type { ReactFlagEvaluationOptions, NormalizedOptions } from '../options';
+
+/**
+ * Default options.
+ * DO NOT EXPORT PUBLICLY
+ * @internal
+ */
+export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
+  updateOnContextChanged: true,
+  updateOnConfigurationChanged: true,
+  suspendUntilReady: false,
+  suspendWhileReconciling: false,
+};
+
+/**
+ * Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
+ * DO NOT EXPORT PUBLICLY
+ * @internal
+ * @param {ReactFlagEvaluationOptions} options  options to normalize
+ * @returns {NormalizedOptions} normalized options
+ */
+export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (
+  options: ReactFlagEvaluationOptions = {},
+) => {
+  const updateOnContextChanged = options.updateOnContextChanged;
+  const updateOnConfigurationChanged = options.updateOnConfigurationChanged;
+
+  // fall-back the suspense options to the catch-all `suspend` property
+  const suspendUntilReady = 'suspendUntilReady' in options ? options.suspendUntilReady : options.suspend;
+  const suspendWhileReconciling =
+    'suspendWhileReconciling' in options ? options.suspendWhileReconciling : options.suspend;
+
+  return {
+    // only return these if properly set (no undefined to allow overriding with spread)
+    ...(typeof suspendUntilReady === 'boolean' && { suspendUntilReady }),
+    ...(typeof suspendWhileReconciling === 'boolean' && { suspendWhileReconciling }),
+    ...(typeof updateOnContextChanged === 'boolean' && { updateOnContextChanged }),
+    ...(typeof updateOnConfigurationChanged === 'boolean' && { updateOnConfigurationChanged }),
+  };
+};
diff --git a/packages/react/src/common/suspense.ts b/packages/react/src/internal/suspense.ts
similarity index 100%
rename from packages/react/src/common/suspense.ts
rename to packages/react/src/internal/suspense.ts
diff --git a/packages/react/src/options.ts b/packages/react/src/options.ts
new file mode 100644
index 00000000..9b6477ea
--- /dev/null
+++ b/packages/react/src/options.ts
@@ -0,0 +1,53 @@
+import type { FlagEvaluationOptions } from '@openfeature/web-sdk';
+
+export type ReactFlagEvaluationOptions = (
+  | {
+      /**
+       * Enable or disable all suspense functionality.
+       * Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
+       * @experimental Suspense is an experimental feature subject to change in future versions.
+       */
+      suspend?: boolean;
+      suspendUntilReady?: never;
+      suspendWhileReconciling?: never;
+    }
+  | {
+      /**
+       * Suspend flag evaluations while the provider is not ready.
+       * Set to false if you don't want to show suspense fallbacks until the provider is initialized.
+       * Defaults to false.
+       * Cannot be used in conjunction with `suspend` option.
+       * @experimental Suspense is an experimental feature subject to change in future versions.
+       */
+      suspendUntilReady?: boolean;
+      /**
+       * Suspend flag evaluations while the provider's context is being reconciled.
+       * Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
+       * Defaults to false.
+       * Cannot be used in conjunction with `suspend` option.
+       * @experimental Suspense is an experimental feature subject to change in future versions.
+       */
+      suspendWhileReconciling?: boolean;
+      suspend?: never;
+    }
+) & {
+  /**
+   * Update the component if the provider emits a ConfigurationChanged event.
+   * Set to false to prevent components from re-rendering when flag value changes
+   * are received by the associated provider.
+   * Defaults to true.
+   */
+  updateOnConfigurationChanged?: boolean;
+  /**
+   * Update the component when the OpenFeature context changes.
+   * Set to false to prevent components from re-rendering when attributes which
+   * may be factors in flag evaluation change.
+   * Defaults to true.
+   */
+  updateOnContextChanged?: boolean;
+} & FlagEvaluationOptions;
+
+// suspense options removed for the useSuspenseFlag hooks
+export type ReactFlagEvaluationNoSuspenseOptions = Omit<ReactFlagEvaluationOptions, 'suspend' | 'suspendUntilReady' | 'suspendWhileReconciling'>;
+
+export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;
diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx
index 02ebc13a..64da42fd 100644
--- a/packages/react/src/provider/provider.tsx
+++ b/packages/react/src/provider/provider.tsx
@@ -1,8 +1,8 @@
 import type { Client} from '@openfeature/web-sdk';
 import { OpenFeature } from '@openfeature/web-sdk';
 import * as React from 'react';
-import type { ReactFlagEvaluationOptions } from '../common';
-import { Context } from '../common';
+import type { ReactFlagEvaluationOptions } from '../options';
+import { Context } from '../internal';
 
 type ClientOrDomain =
   | {
diff --git a/packages/react/src/provider/use-open-feature-client.ts b/packages/react/src/provider/use-open-feature-client.ts
index 5fe3f760..ecd77645 100644
--- a/packages/react/src/provider/use-open-feature-client.ts
+++ b/packages/react/src/provider/use-open-feature-client.ts
@@ -1,5 +1,5 @@
 import React from 'react';
-import { Context } from '../common';
+import { Context } from '../internal';
 import type { Client } from '@openfeature/web-sdk';
 
 /**
diff --git a/packages/react/src/provider/use-when-provider-ready.ts b/packages/react/src/provider/use-when-provider-ready.ts
index 0c2f37da..4cb5d0f0 100644
--- a/packages/react/src/provider/use-when-provider-ready.ts
+++ b/packages/react/src/provider/use-when-provider-ready.ts
@@ -1,8 +1,8 @@
 import { ProviderStatus } from '@openfeature/web-sdk';
 import { useOpenFeatureClient } from './use-open-feature-client';
 import { useOpenFeatureClientStatus } from './use-open-feature-client-status';
-import type { ReactFlagEvaluationOptions} from '../common';
-import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../common';
+import type { ReactFlagEvaluationOptions } from '../options';
+import { DEFAULT_OPTIONS, useProviderOptions, normalizeOptions, suspendUntilReady } from '../internal';
 
 type Options = Pick<ReactFlagEvaluationOptions, 'suspendUntilReady'>;
 
diff --git a/packages/server/README.md b/packages/server/README.md
index dcf04d62..8b6cf8dc 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -383,7 +383,7 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
 
 export class MyHook implements Hook {
   after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
-    // code that runs when there's an error during a flag evaluation
+    // code that runs after flag values are successfully resolved from the provider
   }
 }
 ```
diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts
index 663e1bb3..6d2174df 100644
--- a/packages/server/src/client/internal/open-feature-client.ts
+++ b/packages/server/src/client/internal/open-feature-client.ts
@@ -10,7 +10,9 @@ import type {
   Logger,
   TrackingEventDetails,
   OpenFeatureError,
-  ResolutionDetails} from '@openfeature/core';
+  FlagMetadata,
+  ResolutionDetails,
+} from '@openfeature/core';
 import {
   ErrorCode,
   ProviderFatalError,
@@ -24,7 +26,7 @@ import type { FlagEvaluationOptions } from '../../evaluation';
 import type { ProviderEvents } from '../../events';
 import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
 import type { Hook } from '../../hooks';
-import type { Provider} from '../../provider';
+import type { Provider } from '../../provider';
 import { ProviderStatus } from '../../provider';
 import type { Client } from './../client';
 
@@ -226,7 +228,7 @@ export class OpenFeatureClient implements Client {
     return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
   }
 
-  track(occurrenceKey: string, context: EvaluationContext, occurrenceDetails: TrackingEventDetails): void {
+  track(occurrenceKey: string, context: EvaluationContext = {}, occurrenceDetails: TrackingEventDetails = {}): void {
     try {
       this.shortCircuitIfNotReady();
 
@@ -279,6 +281,8 @@ export class OpenFeatureClient implements Client {
       logger: this._logger,
     };
 
+    let evaluationDetails: EvaluationDetails<T>;
+
     try {
       const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
 
@@ -287,27 +291,27 @@ export class OpenFeatureClient implements Client {
       // run the referenced resolver, binding the provider.
       const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
 
-      const evaluationDetails = {
+      const resolutionDetails = {
         ...resolution,
         flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
         flagKey,
       };
 
-      if (evaluationDetails.errorCode) {
-        const err = instantiateErrorByErrorCode(evaluationDetails.errorCode);
+      if (resolutionDetails.errorCode) {
+        const err = instantiateErrorByErrorCode(resolutionDetails.errorCode);
         await this.errorHooks(allHooksReversed, hookContext, err, options);
-        return this.getErrorEvaluationDetails(flagKey, defaultValue, err);
+        evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
+      } else {
+        await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
+        evaluationDetails = resolutionDetails;
       }
-
-      await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
-
-      return evaluationDetails;
     } catch (err: unknown) {
       await this.errorHooks(allHooksReversed, hookContext, err, options);
-      return this.getErrorEvaluationDetails(flagKey, defaultValue, err);
-    } finally {
-      await this.finallyHooks(allHooksReversed, hookContext, options);
+      evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
     }
+
+    await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
+    return evaluationDetails;
   }
 
   private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
@@ -353,11 +357,16 @@ export class OpenFeatureClient implements Client {
     }
   }
 
-  private async finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
+  private async finallyHooks(
+    hooks: Hook[],
+    hookContext: HookContext,
+    evaluationDetails: EvaluationDetails<FlagValue>,
+    options: FlagEvaluationOptions,
+  ) {
     // run "finally" hooks sequentially
     for (const hook of hooks) {
       try {
-        await hook?.finally?.(hookContext, options.hookHints);
+        await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
       } catch (err) {
         this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
         if (err instanceof Error) {
@@ -403,6 +412,7 @@ export class OpenFeatureClient implements Client {
     flagKey: string,
     defaultValue: T,
     err: unknown,
+    flagMetadata: FlagMetadata = {},
   ): EvaluationDetails<T> {
     const errorMessage: string = (err as Error)?.message;
     const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
@@ -412,7 +422,7 @@ export class OpenFeatureClient implements Client {
       errorMessage,
       value: defaultValue,
       reason: StandardResolutionReasons.ERROR,
-      flagMetadata: Object.freeze({}),
+      flagMetadata: Object.freeze(flagMetadata),
       flagKey,
     };
   }
diff --git a/packages/shared/src/hooks/hook.ts b/packages/shared/src/hooks/hook.ts
index 54233b80..7c3c6337 100644
--- a/packages/shared/src/hooks/hook.ts
+++ b/packages/shared/src/hooks/hook.ts
@@ -32,9 +32,13 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
 
   /**
    * Runs after all other hook stages, regardless of success or error.
-   * Errors thrown here are unhandled by the client and will surface in application code.
    * @param hookContext
+   * @param evaluationDetails
    * @param hookHints
    */
-  finally?(hookContext: Readonly<HookContext<T>>, hookHints?: HookHints): HooksReturn;
+  finally?(
+    hookContext: Readonly<HookContext<T>>,
+    evaluationDetails: EvaluationDetails<T>,
+    hookHints?: HookHints,
+  ): HooksReturn;
 }
diff --git a/packages/shared/src/provider/provider.ts b/packages/shared/src/provider/provider.ts
index e964f1b8..74ae8606 100644
--- a/packages/shared/src/provider/provider.ts
+++ b/packages/shared/src/provider/provider.ts
@@ -133,5 +133,5 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
    * @param context
    * @param trackingEventDetails
    */
-  track?(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
+  track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void;
 }
diff --git a/packages/web/README.md b/packages/web/README.md
index 0106082b..f1f7d41f 100644
--- a/packages/web/README.md
+++ b/packages/web/README.md
@@ -379,7 +379,7 @@ import type { Hook, HookContext, EvaluationDetails, FlagValue } from "@openfeatu
 
 export class MyHook implements Hook {
   after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
-    // code that runs when there's an error during a flag evaluation
+    // code that runs after flag values are successfully resolved from the provider
   }
 }
 ```
diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts
index 6ce25886..d46b82a1 100644
--- a/packages/web/src/client/internal/open-feature-client.ts
+++ b/packages/web/src/client/internal/open-feature-client.ts
@@ -10,7 +10,9 @@ import type {
   Logger,
   TrackingEventDetails,
   OpenFeatureError,
-  ResolutionDetails } from '@openfeature/core';
+  FlagMetadata,
+  ResolutionDetails,
+} from '@openfeature/core';
 import {
   ErrorCode,
   ProviderFatalError,
@@ -24,7 +26,7 @@ import type { FlagEvaluationOptions } from '../../evaluation';
 import type { ProviderEvents } from '../../events';
 import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
 import type { Hook } from '../../hooks';
-import type { Provider} from '../../provider';
+import type { Provider } from '../../provider';
 import { ProviderStatus } from '../../provider';
 import type { Client } from './../client';
 
@@ -183,7 +185,7 @@ export class OpenFeatureClient implements Client {
     return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
   }
 
-  track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails): void {
+  track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails = {}): void {
     try {
       this.shortCircuitIfNotReady();
 
@@ -234,6 +236,8 @@ export class OpenFeatureClient implements Client {
       logger: this._logger,
     };
 
+    let evaluationDetails: EvaluationDetails<T>;
+
     try {
       this.beforeHooks(allHooks, hookContext, options);
 
@@ -242,27 +246,26 @@ export class OpenFeatureClient implements Client {
       // run the referenced resolver, binding the provider.
       const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger);
 
-      const evaluationDetails = {
+      const resolutionDetails = {
         ...resolution,
         flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
         flagKey,
       };
 
-      if (evaluationDetails.errorCode) {
-        const err = instantiateErrorByErrorCode(evaluationDetails.errorCode);
+      if (resolutionDetails.errorCode) {
+        const err = instantiateErrorByErrorCode(resolutionDetails.errorCode);
         this.errorHooks(allHooksReversed, hookContext, err, options);
-        return this.getErrorEvaluationDetails(flagKey, defaultValue, err);
+        evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
+      } else {
+        this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
+        evaluationDetails = resolutionDetails;
       }
-
-      this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
-
-      return evaluationDetails;
     } catch (err: unknown) {
       this.errorHooks(allHooksReversed, hookContext, err, options);
-      return this.getErrorEvaluationDetails(flagKey, defaultValue, err);
-    } finally {
-      this.finallyHooks(allHooksReversed, hookContext, options);
+      evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
     }
+    this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
+    return evaluationDetails;
   }
 
   private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
@@ -301,11 +304,16 @@ export class OpenFeatureClient implements Client {
     }
   }
 
-  private finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
+  private finallyHooks(
+    hooks: Hook[],
+    hookContext: HookContext,
+    evaluationDetails: EvaluationDetails<FlagValue>,
+    options: FlagEvaluationOptions,
+  ) {
     // run "finally" hooks sequentially
     for (const hook of hooks) {
       try {
-        hook?.finally?.(hookContext, options.hookHints);
+        hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
       } catch (err) {
         this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
         if (err instanceof Error) {
@@ -337,6 +345,7 @@ export class OpenFeatureClient implements Client {
     flagKey: string,
     defaultValue: T,
     err: unknown,
+    flagMetadata: FlagMetadata = {},
   ): EvaluationDetails<T> {
     const errorMessage: string = (err as Error)?.message;
     const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
@@ -346,7 +355,7 @@ export class OpenFeatureClient implements Client {
       errorMessage,
       value: defaultValue,
       reason: StandardResolutionReasons.ERROR,
-      flagMetadata: Object.freeze({}),
+      flagMetadata: Object.freeze(flagMetadata),
       flagKey,
     };
   }


Test patch:
diff --git a/packages/react/src/provider/test-provider.tsx b/packages/react/src/provider/test-provider.tsx
index 72746c76..a13f1bd3 100644
--- a/packages/react/src/provider/test-provider.tsx
+++ b/packages/react/src/provider/test-provider.tsx
@@ -7,7 +7,7 @@ import {
   OpenFeature
 } from '@openfeature/web-sdk';
 import React from 'react';
-import type { NormalizedOptions } from '../common';
+import type { NormalizedOptions } from '../options';
 import { OpenFeatureProvider } from './provider';
 
 type FlagValueMap = { [flagKey: string]: JsonValue };
diff --git a/packages/react/test/is-equal.spec.ts b/packages/react/test/is-equal.spec.ts
index 9d2e4dee..025edbad 100644
--- a/packages/react/test/is-equal.spec.ts
+++ b/packages/react/test/is-equal.spec.ts
@@ -1,4 +1,4 @@
-import { isEqual } from '../src/common/is-equal';
+import { isEqual } from '../src/internal/is-equal';
 
 describe('isEqual', () => {
   it('should return true for equal primitive values', () => {
diff --git a/packages/react/test/options.spec.ts b/packages/react/test/options.spec.ts
index c8c04bf8..5b295143 100644
--- a/packages/react/test/options.spec.ts
+++ b/packages/react/test/options.spec.ts
@@ -1,4 +1,4 @@
-import { normalizeOptions } from '../src/common/options';
+import { normalizeOptions } from '../src/internal/options';
 
 describe('normalizeOptions', () => {
   // we spread results from this function, so we never want to return null
diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index 69dc41fa..b1db2741 100644
--- a/packages/server/test/client.spec.ts
+++ b/packages/server/test/client.spec.ts
@@ -859,6 +859,18 @@ describe('OpenFeatureClient', () => {
         }).not.toThrow();
       });
 
+      it('provide empty tracking details to provider if not supplied in call', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
+        const client = OpenFeature.getClient();
+        client.track(eventName);
+
+        expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
+          eventName,
+          expect.any(Object),
+          expect.any(Object),
+        );
+      });
+
       it('should call provider with correct context', async () => {
         await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
         OpenFeature.setContext({ [globalContextKey]: globalContextValue });
diff --git a/packages/server/test/hooks.spec.ts b/packages/server/test/hooks.spec.ts
index 006144f2..542a042b 100644
--- a/packages/server/test/hooks.spec.ts
+++ b/packages/server/test/hooks.spec.ts
@@ -439,6 +439,30 @@ describe('Hooks', () => {
         });
       });
     });
+
+    describe('Requirement 4.3.8', () => {
+      it('"evaluation details" passed to the "finally" stage matches the evaluation details returned to the application author', async () => {
+        OpenFeature.setProvider(MOCK_PROVIDER);
+        let evaluationDetailsHooks;
+
+        const evaluationDetails = await client.getBooleanDetails(
+          FLAG_KEY,
+          false,
+          {},
+          {
+            hooks: [
+              {
+                finally: (_, details) => {
+                  evaluationDetailsHooks = details;
+                },
+              },
+            ],
+          },
+        );
+
+        expect(evaluationDetailsHooks).toEqual(evaluationDetails);
+      });
+    });
   });
 
   describe('Requirement 4.4.2', () => {
@@ -922,14 +946,14 @@ describe('Hooks', () => {
                 done(err);
               }
             },
-            after: (_hookContext, _evaluationDetils, hookHints) => {
+            after: (_hookContext, _evaluationDetails, hookHints) => {
               try {
                 expect(hookHints?.hint).toBeTruthy();
               } catch (err) {
                 done(err);
               }
             },
-            finally: (_, hookHints) => {
+            finally: (_, _evaluationDetails, hookHints) => {
               try {
                 expect(hookHints?.hint).toBeTruthy();
                 done();
diff --git a/packages/web/test/client.spec.ts b/packages/web/test/client.spec.ts
index 381dd9b2..c67b593a 100644
--- a/packages/web/test/client.spec.ts
+++ b/packages/web/test/client.spec.ts
@@ -655,6 +655,18 @@ describe('OpenFeatureClient', () => {
         }).not.toThrow();
       });
 
+      it('provide empty tracking details to provider if not supplied in call', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
+        const client = OpenFeature.getClient();
+        client.track(eventName);
+
+        expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
+          eventName,
+          expect.any(Object),
+          expect.any(Object),
+        );
+      });
+
       it('should no-op and not throw if provider throws', async () => {
         await OpenFeature.setProviderAndWait({
           ...MOCK_PROVIDER,
diff --git a/packages/web/test/hooks.spec.ts b/packages/web/test/hooks.spec.ts
index 20583a31..bb849c38 100644
--- a/packages/web/test/hooks.spec.ts
+++ b/packages/web/test/hooks.spec.ts
@@ -1,16 +1,5 @@
-import type {
-  Provider,
-  ResolutionDetails,
-  Client,
-  FlagValueType,
-  EvaluationContext,
-  Hook} from '../src';
-import {
-  GeneralError,
-  OpenFeature,
-  StandardResolutionReasons,
-  ErrorCode,
-} from '../src';
+import type { Provider, ResolutionDetails, Client, FlagValueType, EvaluationContext, Hook } from '../src';
+import { GeneralError, OpenFeature, StandardResolutionReasons, ErrorCode } from '../src';
 
 const BOOLEAN_VALUE = true;
 
@@ -282,6 +271,25 @@ describe('Hooks', () => {
         });
       });
     });
+
+    describe('Requirement 4.3.8', () => {
+      it('"evaluation details" passed to the "finally" stage matches the evaluation details returned to the application author', () => {
+        OpenFeature.setProvider(MOCK_PROVIDER);
+        let evaluationDetailsHooks;
+
+        const evaluationDetails = client.getBooleanDetails(FLAG_KEY, false, {
+          hooks: [
+            {
+              finally: (_, details) => {
+                evaluationDetailsHooks = details;
+              },
+            },
+          ],
+        });
+
+        expect(evaluationDetailsHooks).toEqual(evaluationDetails);
+      });
+    });
   });
 
   describe('Requirement 4.4.2', () => {
@@ -759,14 +767,14 @@ describe('Hooks', () => {
                 done(err);
               }
             },
-            after: (_hookContext, _evaluationDetils, hookHints) => {
+            after: (_hookContext, _evaluationDetails, hookHints) => {
               try {
                 expect(hookHints?.hint).toBeTruthy();
               } catch (err) {
                 done(err);
               }
             },
-            finally: (_, hookHints) => {
+            finally: (_, _evaluationDetails, hookHints) => {
               try {
                 expect(hookHints?.hint).toBeTruthy();
                 done();