Skip to content

Latest commit

Β 

History

History
1557 lines (1179 loc) Β· 49.7 KB

File metadata and controls

1557 lines (1179 loc) Β· 49.7 KB

Feature Flags

πŸ“‹ Table of Contents

🎯 Overview

Feature flags (also known as feature toggles) are a software development technique that allows you to enable or disable features at runtime without deploying new code. This provides powerful capabilities for:

  • Progressive rollouts: Release features to specific users or groups
  • A/B testing: Compare different implementations with real users
  • Quick rollbacks: Disable problematic features instantly without redeployment
  • Environment-specific features: Enable features in dev/staging but not production
  • Dark launches: Deploy code to production but keep features hidden until ready

Why OpenFeature + LaunchDarkly?

LFX One uses OpenFeature as a vendor-agnostic abstraction layer with LaunchDarkly as the feature flag provider:

  • OpenFeature: Provides a standardized API for feature flags, preventing vendor lock-in
  • LaunchDarkly: Enterprise-grade feature flag platform with real-time updates, targeting rules, and analytics
  • Signal-based integration: Native Angular 20 signals provide automatic reactivity without manual change detection

Benefits for LFX One

  1. Zoneless Compatibility: Fully compatible with Angular 20's stable zoneless change detection
  2. Real-time Updates: Flag changes in LaunchDarkly propagate instantly to the UI without refresh
  3. Type Safety: TypeScript interfaces ensure compile-time safety for flag values
  4. SSR Support: Graceful handling of server-side rendering with browser-only initialization
  5. Developer Experience: Simple signal-based API requires no manual subscriptions or change detection

πŸ— Architecture

System Overview

The feature flag system consists of three main components:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     Application Startup                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                               β”‚
β”‚  1. provideAppInitializer (provideFeatureFlags)             β”‚
β”‚     └─> Initialize LaunchDarkly Provider                     β”‚
β”‚         └─> OpenFeature.setProviderAndWait()                β”‚
β”‚                                                               β”‚
β”‚  2. app.component.ts Constructor                             β”‚
β”‚     └─> Get authenticated user from Auth0                    β”‚
β”‚         └─> featureFlagService.initialize(user)             β”‚
β”‚             └─> Set user context in OpenFeature             β”‚
β”‚             └─> Get OpenFeature client                       β”‚
β”‚             └─> Set isInitialized = true                     β”‚
β”‚             └─> Setup event handlers                         β”‚
β”‚                                                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    FeatureFlagService                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                               β”‚
β”‚  Private State:                                              β”‚
β”‚    β€’ client: OpenFeature Client                             β”‚
β”‚    β€’ isInitialized: WritableSignal<boolean>                 β”‚
β”‚    β€’ context: WritableSignal<EvaluationContext>             β”‚
β”‚                                                               β”‚
β”‚  Public API (all return Signal<T>):                         β”‚
β”‚    β€’ initialized: Signal<boolean>                            β”‚
β”‚    β€’ getBooleanFlag(key, default): Signal<boolean>          β”‚
β”‚    β€’ getStringFlag(key, default): Signal<string>            β”‚
β”‚    β€’ getNumberFlag(key, default): Signal<number>            β”‚
β”‚    β€’ getObjectFlag(key, default): Signal<T>                 β”‚
β”‚                                                               β”‚
β”‚  Event Handlers:                                             β”‚
β”‚    β€’ Ready, ConfigurationChanged, ContextChanged            β”‚
β”‚    β€’ All trigger refreshFlags() β†’ context signal update     β”‚
β”‚                                                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Components                              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                               β”‚
β”‚  1. Inject FeatureFlagService                                β”‚
β”‚  2. Call getBooleanFlag('flag-key', defaultValue)           β”‚
β”‚  3. Receive Signal<boolean>                                  β”‚
β”‚  4. Use in computed() or directly in template               β”‚
β”‚  5. Automatic re-render on flag changes                      β”‚
β”‚                                                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Signal-Based Reactivity

The service uses Angular signals for automatic reactivity:

  1. Context Signal: Private context signal triggers re-evaluation when updated
  2. Computed Flag Signals: Each getXxxFlag() method returns a computed signal that:
    • Depends on the context signal
    • Re-evaluates when context changes
    • Automatically updates the UI without manual change detection
  3. Event-Driven Updates: LaunchDarkly provider events trigger context refresh, causing all flag signals to re-evaluate

This design provides:

  • Zero Subscriptions: No manual subscribe/unsubscribe management
  • Automatic Cleanup: Signals are garbage collected with components
  • Zoneless Compatible: Works perfectly with Angular 20's zoneless change detection
  • Referential Stability: Same signal instance is returned across calls (use computed() for derived logic)

πŸ”§ Implementation Details

Service Configuration

The FeatureFlagService is located at src/app/shared/services/feature-flag.service.ts:

@Injectable({
  providedIn: 'root',
})
export class FeatureFlagService {
  private client: Client | null = null;
  private readonly isInitialized = signal<boolean>(false);
  private readonly context = signal<EvaluationContext | null>(null);

  // Public readonly signals
  public readonly initialized = this.isInitialized.asReadonly();

  /**
   * Initialize OpenFeature client with user context
   * Call this method from app.component when user is authenticated
   */
  public async initialize(user: any): Promise<void> {
    if (this.isInitialized()) {
      return;
    }

    try {
      const userContext: EvaluationContext = {
        kind: 'user',
        name: user.name || '',
        email: user.email || '',
        targetingKey: user.preferred_username || user.username || user.sub || '',
      };

      await OpenFeature.setContext(userContext);
      this.client = OpenFeature.getClient();
      this.context.set(userContext);
      this.isInitialized.set(true);

      this.setupEventHandlers();
    } catch (error) {
      console.error('Failed to initialize feature flag service:', error);
      this.isInitialized.set(false);
    }
  }
}

Key Design Decisions:

  • Singleton Service: providedIn: 'root' ensures single instance across application
  • Private State: client, isInitialized, and context are private for encapsulation
  • Public Readonly Signals: Exposed signals use asReadonly() to prevent external mutation
  • Lazy Initialization: Service doesn't initialize in constructor; waits for explicit initialize() call
  • Idempotent: Multiple initialize() calls are safe (checks isInitialized() first)

Provider Setup

The feature flag system uses two providers working together:

  1. runtime-config.provider.ts: Sets up runtime configuration via TransferState
  2. feature-flag.provider.ts: Initializes LaunchDarkly using the runtime config

Runtime Config Provider (src/app/shared/providers/runtime-config.provider.ts):

import { EnvironmentProviders, inject, makeStateKey, provideAppInitializer, REQUEST_CONTEXT, TransferState } from '@angular/core';
import { RuntimeConfig } from '@lfx-one/shared';

export const RUNTIME_CONFIG_KEY = makeStateKey<RuntimeConfig>('runtimeConfig');

export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
  launchDarklyClientId: '',
  dataDogRumClientId: '',
  dataDogRumApplicationId: '',
};

async function initializeRuntimeConfig(): Promise<void> {
  const transferState = inject(TransferState);
  const reqContext = inject(REQUEST_CONTEXT, { optional: true }) as {
    runtimeConfig: RuntimeConfig;
  } | null;

  // Server-side: Store config to TransferState for browser hydration
  if (reqContext?.runtimeConfig) {
    transferState.set(RUNTIME_CONFIG_KEY, reqContext.runtimeConfig);
  }
}

export const provideRuntimeConfig = (): EnvironmentProviders => provideAppInitializer(initializeRuntimeConfig);

export function getRuntimeConfig(transferState: TransferState): RuntimeConfig {
  return transferState.get(RUNTIME_CONFIG_KEY, DEFAULT_RUNTIME_CONFIG);
}

Feature Flag Provider (src/app/shared/providers/feature-flag.provider.ts):

import { EnvironmentProviders, inject, provideAppInitializer, TransferState } from '@angular/core';
import { environment } from '@environments/environment';
import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider';
import { OpenFeature } from '@openfeature/web-sdk';
import { basicLogger } from 'launchdarkly-js-client-sdk';

import { getRuntimeConfig } from './runtime-config.provider';

async function initializeOpenFeature(): Promise<void> {
  // Skip on server - LaunchDarkly is browser-only
  if (typeof window === 'undefined') {
    return;
  }

  const transferState = inject(TransferState);
  const runtimeConfig = getRuntimeConfig(transferState);
  const clientId = runtimeConfig.launchDarklyClientId;

  // Skip if no client ID is configured
  if (!clientId) {
    console.warn('LaunchDarkly client ID not configured - feature flags disabled');
    return;
  }

  try {
    const provider = new LaunchDarklyClientProvider(clientId, {
      initializationTimeout: 5,
      streaming: true,
      logger: basicLogger({ level: environment.production ? 'none' : 'info' }),
    });

    await OpenFeature.setProviderAndWait(provider);
  } catch (error) {
    console.error('Failed to initialize OpenFeature with LaunchDarkly:', error);
    // App continues without feature flags
  }
}

export const provideFeatureFlags = (): EnvironmentProviders => provideAppInitializer(initializeOpenFeature);

App Config (src/app/app.config.ts):

import { provideRuntimeConfig } from './shared/providers/runtime-config.provider';
import { provideFeatureFlags } from './shared/providers/feature-flag.provider';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...other providers
    provideRuntimeConfig(), // Must be before providers that depend on runtime config
    provideFeatureFlags(),
    // ...other providers
  ],
};

Provider Configuration:

  • provideAppInitializer: Angular 20's modern API for app initialization, runs during application bootstrap before components render
  • Runtime Config via TransferState: Client IDs are passed from server to browser via Angular's TransferState mechanism
  • SSR Guard: typeof window === 'undefined' check prevents server-side execution of LaunchDarkly
  • Graceful Degradation: Missing configuration or errors allow app to continue with default flag values
  • Streaming Mode: Real-time flag updates from LaunchDarkly
  • Environment-Aware Logging: Info level in development, none in production

For more details on the runtime configuration architecture, see Runtime Configuration.

Event Handling

The service sets up event handlers for real-time flag updates:

private setupEventHandlers(): void {
  if (!this.client) {
    return;
  }

  const forceSignalUpdate = () => {
    // Force re-evaluation by updating context reference
    this.refreshFlags();
  };

  // Set up event handlers for flag changes
  this.client.addHandler(ProviderEvents.Ready, forceSignalUpdate);
  this.client.addHandler(ProviderEvents.ConfigurationChanged, forceSignalUpdate);
  this.client.addHandler(ProviderEvents.ContextChanged, forceSignalUpdate);
  this.client.addHandler(ProviderEvents.Reconciling, forceSignalUpdate);
  this.client.addHandler(ProviderEvents.Stale, forceSignalUpdate);
  this.client.addHandler(ProviderEvents.Error, () => {
    console.error('Feature flag provider error');
  });
}

private refreshFlags(): void {
  const current = this.context();
  if (current) {
    this.context.set({ ...current });
  }
}

Event Types:

  • Ready: Provider is ready to evaluate flags
  • ConfigurationChanged: Flag configuration updated in LaunchDarkly
  • ContextChanged: User context changed (different user, attributes updated)
  • Reconciling: Provider is syncing with LaunchDarkly
  • Stale: Cached data is stale, reconciliation needed
  • Error: Provider encountered an error

Update Mechanism:

The refreshFlags() method creates a new context reference ({ ...current }), which:

  1. Triggers Angular's signal change detection
  2. Causes all computed flag signals to re-evaluate
  3. Updates the UI automatically
  4. Maintains referential integrity for computed signals

πŸš€ Usage Patterns

Basic Boolean Flags

The most common pattern is boolean flags for feature toggles:

export class MyComponent {
  private readonly featureFlagService = inject(FeatureFlagService);

  // Create a signal that tracks the flag value
  protected readonly showNewFeature = this.featureFlagService.getBooleanFlag('new-feature', false);
}
<!-- Use directly in template -->
@if (showNewFeature()) {
<div>New Feature Content</div>
}

API:

getBooleanFlag(key: string, defaultValue: boolean = false): Signal<boolean>

Parameters:

  • key: String identifier for the flag (matches LaunchDarkly flag key)
  • defaultValue: Value returned when flag doesn't exist or service isn't initialized

Returns: A reactive Signal<boolean> that updates automatically

Conditional Rendering

Use feature flags to show/hide UI elements with Angular's @if syntax:

Example from board-member-dashboard.component.ts:

export class BoardMemberDashboardComponent {
  private readonly featureFlagService = inject(FeatureFlagService);

  // Feature flag to control organization selector visibility
  protected readonly showOrganizationSelector = this.featureFlagService.getBooleanFlag('organization-selector', true);
}

Template (board-member-dashboard.component.html):

<!-- Organization Selector -->
@if (showOrganizationSelector()) {
<div class="mb-6 flex items-center gap-4" data-testid="organization-selector">
  <label for="organization-select" class="text-sm font-semibold text-gray-700">Organization:</label>
  <lfx-select
    [form]="form"
    control="selectedAccountId"
    [options]="availableAccounts()"
    optionLabel="accountName"
    optionValue="accountId"
    [filter]="true"
    filterPlaceholder="Search organizations..."
    placeholder="Select an organization"
    [showClear]="false"
    styleClass="min-w-[300px]"
    inputId="organization-select"
    data-testid="organization-select" />
</div>
}

Pattern Benefits:

  • Declarative: Template clearly shows conditional logic
  • Performance: Angular only renders content when flag is true
  • Type-Safe: TypeScript ensures signal invocation with ()
  • Reactive: Changes in LaunchDarkly instantly show/hide element

Array Filtering

Use feature flags with computed() signals to dynamically filter arrays:

Example from main-layout.component.ts:

export class MainLayoutComponent {
  private readonly featureFlagService = inject(FeatureFlagService);

  // Feature flag for sidebar projects visibility
  private readonly showProjectsInSidebar = this.featureFlagService.getBooleanFlag('sidebar-projects', true);

  // Base sidebar navigation items
  private readonly baseSidebarItems: SidebarMenuItem[] = [
    {
      label: 'Overview',
      icon: 'fa-light fa-grid-2',
      routerLink: '/',
    },
    {
      label: 'Meetings',
      icon: 'fa-light fa-calendar',
      routerLink: '/meetings',
    },
    {
      label: 'Projects',
      icon: 'fa-light fa-folder-open',
      routerLink: '/projects',
    },
  ];

  // Computed sidebar items based on feature flags
  protected readonly sidebarItems = computed(() => {
    const items = [...this.baseSidebarItems];

    // Filter out Projects if feature flag is disabled
    if (!this.showProjectsInSidebar()) {
      return items.filter((item) => item.label !== 'Projects');
    }

    return items;
  });
}

Template (main-layout.component.html):

<!-- Sidebar with filtered items -->
<lfx-sidebar [items]="sidebarItems()" [footerItems]="sidebarFooterItems" [showProjectSelector]="true"></lfx-sidebar>

Pattern Benefits:

  • Separation of Concerns: Base data separate from filtering logic
  • Composable: Multiple flags can filter different items independently
  • Reactive: Computed signal automatically updates when any dependency changes
  • Performance: Only recomputes when flag values change

Additional Flag Types

Beyond booleans, the service supports string, number, and object flags:

String Flags

// Get a string configuration value
protected readonly apiEndpoint = this.featureFlagService.getStringFlag('api-endpoint', 'https://api.example.com');

API:

getStringFlag(key: string, defaultValue: string = ''): Signal<string>

Use Cases: API endpoints, theme names, configuration strings

Number Flags

// Get a numeric configuration value
protected readonly maxResults = this.featureFlagService.getNumberFlag('max-results', 50);

API:

getNumberFlag(key: string, defaultValue: number = 0): Signal<number>

Use Cases: Pagination limits, timeout values, thresholds

Object Flags

// Get a complex configuration object
interface FeatureConfig {
  enabled: boolean;
  options: string[];
  threshold: number;
}

protected readonly featureConfig = this.featureFlagService.getObjectFlag<FeatureConfig>('feature-config', {
  enabled: false,
  options: [],
  threshold: 0,
});

API:

getObjectFlag<T extends JsonValue = JsonValue>(key: string, defaultValue: T): Signal<T>

Use Cases: Complex configurations, multi-option settings, nested configuration objects

Important: Object flags must be JSON-serializable (primitives, arrays, objects only - no functions or class instances)

πŸ”„ Reactivity & Change Detection

Signal-Based Updates

The feature flag system uses Angular signals for automatic reactivity:

public getBooleanFlag(key: string, defaultValue: boolean = false): Signal<boolean> {
  return computed(() => {
    // Reactive dependency on context signal
    this.context();

    if (!this.isInitialized() || !this.client) {
      return defaultValue;
    }

    try {
      return this.client.getBooleanValue(key, defaultValue);
    } catch (error) {
      console.error(`Error evaluating boolean flag '${key}':`, error);
      return defaultValue;
    }
  });
}

How It Works:

  1. Computed Signal: Each flag method returns a computed() signal
  2. Context Dependency: The computed signal reads this.context(), creating a reactive dependency
  3. Automatic Tracking: Angular tracks this dependency and re-runs the computation when context changes
  4. UI Updates: Components using the flag signal automatically re-render when the flag value changes

No Manual Change Detection:

  • No need to call ChangeDetectorRef.markForCheck()
  • No need to run code in NgZone
  • No need to use async pipe
  • Works seamlessly with zoneless change detection

Real-Time Flag Updates

When flags change in LaunchDarkly:

1. LaunchDarkly emits ConfigurationChanged event
   ↓
2. Event handler calls refreshFlags()
   ↓
3. refreshFlags() updates context signal with new reference
   ↓
4. All computed flag signals detect context change
   ↓
5. Computed signals re-evaluate and get new flag values
   ↓
6. Components using these signals automatically re-render
   ↓
7. User sees updated UI instantly (no page refresh)

Example Timeline:

Time  | Action                                    | Result
------|-------------------------------------------|---------------------------
T+0   | Developer toggles 'new-feature' in LD    | Event queued
T+50ms| ConfigurationChanged event received      | refreshFlags() called
T+51ms| context signal updated                   | Flag signals re-evaluate
T+52ms| showNewFeature() returns new value       | Component re-renders
T+53ms| Template @if condition evaluated         | UI updated

Streaming Connection:

The LaunchDarkly provider uses Server-Sent Events (SSE) for real-time updates:

  • Persistent Connection: Maintained in the background
  • Low Latency: Updates typically arrive within 50-200ms
  • Automatic Reconnection: Provider handles connection failures
  • Graceful Degradation: Falls back to polling if SSE unavailable

βš™οΈ Configuration

Runtime Environment Variables

Feature flags use runtime configuration injection instead of build-time environment files. This allows a single Docker image to be deployed to any environment with different client IDs.

Required Environment Variables:

Variable Description Example
LD_CLIENT_ID LaunchDarkly client-side ID 691b727361cbf309e9d74468
DD_RUM_CLIENT_ID DataDog RUM client token (future) pub123456789
DD_RUM_APPLICATION_ID DataDog RUM application ID (future) app-uuid-here

Local Development:

Add to your .env file (already gitignored):

# Runtime Client IDs for local development
LD_CLIENT_ID=your-launchdarkly-dev-client-id
DD_RUM_CLIENT_ID=your-datadog-rum-client-token
DD_RUM_APPLICATION_ID=your-datadog-rum-app-id

The server reads these via dotenv in development mode.

Docker Deployment:

Pass environment variables at container runtime:

docker run \
  -e LD_CLIENT_ID=prod-client-id \
  -e DD_RUM_CLIENT_ID=prod-rum-token \
  -e DD_RUM_APPLICATION_ID=prod-rum-app-id \
  ghcr.io/linuxfoundation/lfx-v2-ui:latest

Kubernetes Deployment:

Configure in your Kubernetes manifests or Helm values:

env:
  - name: LD_CLIENT_ID
    valueFrom:
      secretKeyRef:
        name: lfx-one-secrets
        key: launchdarkly-client-id

Getting Your Client ID:

  1. Log in to LaunchDarkly dashboard
  2. Navigate to Account Settings β†’ Projects
  3. Select your project and environment (Development, Staging, Production)
  4. Copy the "Client-side ID" (not the SDK key)
  5. Set as LD_CLIENT_ID environment variable

Important:

  • Use different client IDs for each environment via environment variables
  • Client-side IDs are safe to use in runtime configuration (they're public)
  • Server-side SDK keys should never be in client code
  • See Runtime Configuration for complete architecture details

LaunchDarkly Provider Options

The provider is configured in feature-flag.provider.ts:

const provider = new LaunchDarklyClientProvider(clientId, {
  initializationTimeout: 5, // seconds
  streaming: true,
  logger: basicLogger({ level: environment.production ? 'none' : 'info' }),
});

Note: clientId comes from runtime configuration via TransferState, not from environment files.

Configuration Options:

Option Type Default Description
initializationTimeout number 5 Max seconds to wait for initial flag fetch
streaming boolean true Enable real-time updates via Server-Sent Events
logger object none LaunchDarkly logger for debugging
bootstrap object - Pre-populate flags (useful for SSR)
sendEventsOnlyForVariation boolean false Reduce analytics events sent to LaunchDarkly

Logger Levels:

  • 'none': No logging (production)
  • 'error': Errors only
  • 'warn': Errors and warnings
  • 'info': Errors, warnings, and info (development)
  • 'debug': All messages including debug info

Production Recommendations:

// Production: Minimal logging, conservative timeout
{
  initializationTimeout: 3,
  streaming: true,
  logger: basicLogger({ level: 'none' }),
  sendEventsOnlyForVariation: true,
}

// Development: Verbose logging, longer timeout for debugging
{
  initializationTimeout: 10,
  streaming: true,
  logger: basicLogger({ level: 'info' }),
}

User Context

User context enables targeted flag delivery based on user attributes:

const userContext: EvaluationContext = {
  kind: 'user',
  name: user.name || '',
  email: user.email || '',
  targetingKey: user.preferred_username || user.username || user.sub || '',
};

Context Structure:

Field Type Required Description
kind string Yes Always 'user' for user contexts
targetingKey string Yes Unique identifier for the user
name string No Display name for LaunchDarkly UI
email string No Email for targeting rules
Custom attributes any No Additional attributes for targeting

Targeting Use Cases:

  1. User-Specific Rollouts: Enable feature for specific users by email or ID
  2. Percentage Rollouts: Show feature to 10% of users randomly
  3. Segment Targeting: Enable for specific user groups (e.g., beta testers)
  4. A/B Testing: Show variant A to 50% of users, variant B to 50%

Adding Custom Attributes:

const userContext: EvaluationContext = {
  kind: 'user',
  targetingKey: user.sub,
  name: user.name,
  email: user.email,
  // Custom attributes for targeting
  organization: user.organization,
  role: user.role,
  plan: user.subscriptionPlan,
  betaTester: user.isBetaTester,
};

Custom attributes can then be used in LaunchDarkly targeting rules.

πŸ“ Best Practices

Service Initialization

βœ… DO: Initialize in app.component after authentication

export class AppComponent implements OnInit {
  private readonly featureFlagService = inject(FeatureFlagService);

  constructor() {
    const user = /* get authenticated user */;
    if (user) {
      this.featureFlagService.initialize(user);
    }
  }
}

❌ DON'T: Initialize in service constructor

// BAD: Service doesn't have access to user during construction
constructor() {
  this.initialize(); // No user context available!
}

Why: The service needs user context for targeting rules. User information is only available after authentication completes.

Component Patterns

βœ… DO: Inject service at component level

export class MyComponent {
  private readonly featureFlagService = inject(FeatureFlagService);
  protected readonly showFeature = this.featureFlagService.getBooleanFlag('my-feature', false);
}

❌ DON'T: Create service instances manually

// BAD: Bypasses dependency injection
const service = new FeatureFlagService();

Why: Angular's dependency injection ensures proper singleton behavior and lifecycle management.

Default Values

βœ… DO: Provide sensible defaults matching current behavior

// Default true maintains current behavior if flag doesn't exist
protected readonly showOrganizationSelector = this.featureFlagService.getBooleanFlag('organization-selector', true);

❌ DON'T: Use false as default for existing features

// BAD: Existing feature will disappear if flag not found
protected readonly showExistingFeature = this.featureFlagService.getBooleanFlag('existing-feature', false);

Why: Default values are used when:

  • Flag doesn't exist in LaunchDarkly
  • Service initialization fails
  • Network is offline
  • LaunchDarkly is unreachable

Choose defaults that maintain current application behavior to avoid breaking changes during outages.

Signal Usage

βœ… DO: Use computed() for derived flag logic

protected readonly sidebarItems = computed(() => {
  const items = [...this.baseSidebarItems];

  if (!this.showProjectsInSidebar()) {
    return items.filter((item) => item.label !== 'Projects');
  }

  return items;
});

❌ DON'T: Use effect() to update other signals

// BAD: Creates unnecessary complexity and potential infinite loops
effect(() => {
  if (this.showProjectsInSidebar()) {
    this.sidebarItems.set([...]); // Avoid!
  }
});

Why: computed() signals:

  • Automatically track dependencies
  • Only recompute when dependencies change
  • Are easier to test and reason about
  • Avoid potential infinite loops

Template Patterns

βœ… DO: Invoke signals in templates with ()

@if (showFeature()) {
<div>Content</div>
}

❌ DON'T: Pass signals without invoking

<!-- BAD: Type error - Signal<boolean> is not assignable to boolean -->
@if (showFeature) {
<div>Content</div>
}

Why: Signals are functions that return values. You must invoke them to get the current value.

Flag Key Management

βœ… DO: Use inline string literals for flag keys

protected readonly showFeature = this.featureFlagService.getBooleanFlag('new-feature', false);

βœ… ALSO ACCEPTABLE: Centralize in constants for reuse

// shared/constants/feature-flags.ts
export const FEATURE_FLAGS = {
  NEW_FEATURE: 'new-feature',
  SIDEBAR_PROJECTS: 'sidebar-projects',
} as const;

// component
protected readonly showFeature = this.featureFlagService.getBooleanFlag(FEATURE_FLAGS.NEW_FEATURE, false);

Benefits of constants:

  • Autocomplete in IDE
  • Refactoring support
  • Single source of truth
  • Type safety for flag keys

Performance Considerations

βœ… DO: Create flag signals at component initialization

export class MyComponent {
  // Created once during component construction
  protected readonly showFeature = this.featureFlagService.getBooleanFlag('my-feature', false);
}

❌ DON'T: Create flag signals in methods or templates

// BAD: Creates new signal on every method call
public get showFeature() {
  return this.featureFlagService.getBooleanFlag('my-feature', false);
}

Why: Creating signals repeatedly causes:

  • Unnecessary memory allocation
  • Loss of referential stability for computed signals
  • Potential performance issues in templates

Error Handling

βœ… DO: Handle initialization failures gracefully

async ngOnInit() {
  try {
    await this.featureFlagService.initialize(this.user);
  } catch (error) {
    // Log error but continue - default values will be used
    console.error('Feature flag initialization failed:', error);
  }
}

βœ… DO: Provide fallback UI for critical features

@if (isLoading()) {
  <lfx-skeleton></lfx-skeleton>
} @else if (showNewDashboard()) {
  <app-new-dashboard></app-new-dashboard>
} @else {
  <app-legacy-dashboard></app-legacy-dashboard>
}

Why: Graceful degradation ensures users can still use the application even when feature flags are unavailable.

🚨 Error Handling & Fallbacks

Initialization Failures

The service handles initialization failures gracefully:

try {
  const provider = new LaunchDarklyClientProvider(environment.launchDarklyClientId, {
    initializationTimeout: 5,
    streaming: true,
  });

  await OpenFeature.setProviderAndWait(provider);
} catch (error) {
  console.error('Failed to initialize OpenFeature with LaunchDarkly:', error);
  // App continues without feature flags
}

Failure Scenarios:

  1. Missing Client ID: Console warning, flags disabled, defaults used
  2. Network Timeout: Provider initialization fails, defaults used
  3. Invalid Client ID: LaunchDarkly rejects connection, defaults used
  4. Server-Side Rendering: Initialization skipped (browser-only check)

Application Behavior:

  • Application continues to function normally
  • All feature flags return their default values
  • No user-facing errors or broken UI
  • Console logs provide debugging information

Runtime Errors

Individual flag evaluations are wrapped in try-catch:

try {
  return this.client.getBooleanValue(key, defaultValue);
} catch (error) {
  console.error(`Error evaluating boolean flag '${key}':`, error);
  return defaultValue;
}

Error Scenarios:

  • Flag key doesn't exist in LaunchDarkly
  • Type mismatch (requesting boolean for string flag)
  • Evaluation context invalid
  • Client disconnected

Fallback Strategy:

  1. Log error to console for debugging
  2. Return the provided default value
  3. Continue application execution
  4. No user-facing error messages

Network Resilience

The LaunchDarkly provider handles network issues:

Offline Behavior:

  • Uses cached flag values from previous session
  • Falls back to defaults for new flags
  • Automatically reconnects when network available
  • Queues analytics events for later delivery

Reconnection Strategy:

1. Connection lost
   ↓
2. Provider emits Stale event
   ↓
3. Provider attempts reconnection with exponential backoff
   ↓
4. On reconnection: Fetches latest flag values
   ↓
5. Emits ConfigurationChanged event
   ↓
6. UI updates with latest values

Console Logging

The system logs errors at appropriate levels:

Development:

// Info level logging for debugging
logger: basicLogger({ level: 'info' });

Production:

// No logging to reduce noise
logger: basicLogger({ level: 'none' });

Error Types:

Severity Message When
WARN "LaunchDarkly client ID not configured" Missing environment variable
ERROR "Failed to initialize OpenFeature" Provider initialization fails
ERROR "Failed to initialize feature flag service" User context setup fails
ERROR "Error evaluating [type] flag '[key]'" Individual flag error
ERROR "Feature flag provider error" Provider runtime error

🎨 Real-World Examples

Example 1: Conditional Feature Access

Show or hide the organization selector in the board member dashboard:

Component (board-member-dashboard.component.ts):

import { Component, inject, Signal, computed } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';

import { FeatureFlagService } from '../../../shared/services/feature-flag.service';
import { Account } from '@lfx-one/shared/interfaces';

export class BoardMemberDashboardComponent {
  private readonly accountContextService = inject(AccountContextService);
  private readonly featureFlagService = inject(FeatureFlagService);

  public readonly form = new FormGroup({
    selectedAccountId: new FormControl<string>(this.accountContextService.selectedAccount().accountId),
  });

  public readonly availableAccounts: Signal<Account[]> = computed(() => this.accountContextService.availableAccounts);

  // Feature flag: show/hide organization selector
  // Default true maintains current behavior
  protected readonly showOrganizationSelector = this.featureFlagService.getBooleanFlag('organization-selector', true);
}

Template (board-member-dashboard.component.html):

<!-- Organization Selector - conditionally rendered -->
@if (showOrganizationSelector()) {
<div class="mb-6 flex items-center gap-4" data-testid="organization-selector">
  <label for="organization-select" class="text-sm font-semibold text-gray-700">Organization:</label>
  <lfx-select
    [form]="form"
    control="selectedAccountId"
    [options]="availableAccounts()"
    optionLabel="accountName"
    optionValue="accountId"
    [filter]="true"
    filterPlaceholder="Search organizations..."
    placeholder="Select an organization"
    [showClear]="false"
    styleClass="min-w-[300px]"
    inputId="organization-select"
    data-testid="organization-select" />
</div>
}

<!-- Rest of dashboard content always visible -->
<div class="dashboard-content">
  <!-- ... -->
</div>

Use Cases:

  • Progressive Rollout: Test organization selector with specific users first
  • A/B Testing: Compare dashboard usage with/without selector
  • Quick Rollback: Disable immediately if performance issues occur
  • Environment Control: Hide in production but show in staging

Example 2: Navigation Filtering

Dynamically filter sidebar navigation items based on feature flags:

Component (main-layout.component.ts):

import { Component, inject, computed } from '@angular/core';
import { Router } from '@angular/router';

import { FeatureFlagService } from '../../shared/services/feature-flag.service';
import { AppService } from '../../shared/services/app.service';
import { SidebarMenuItem } from '@lfx-one/shared/interfaces';

export class MainLayoutComponent {
  private readonly router = inject(Router);
  private readonly appService = inject(AppService);
  private readonly featureFlagService = inject(FeatureFlagService);

  // Feature flag: show/hide Projects in sidebar
  private readonly showProjectsInSidebar = this.featureFlagService.getBooleanFlag('sidebar-projects', true);

  // Base sidebar navigation items - complete set
  private readonly baseSidebarItems: SidebarMenuItem[] = [
    {
      label: 'Overview',
      icon: 'fa-light fa-grid-2',
      routerLink: '/',
    },
    {
      label: 'Meetings',
      icon: 'fa-light fa-calendar',
      routerLink: '/meetings',
    },
    {
      label: 'Projects',
      icon: 'fa-light fa-folder-open',
      routerLink: '/projects',
    },
  ];

  // Computed sidebar items based on feature flags
  // Re-evaluates automatically when showProjectsInSidebar changes
  protected readonly sidebarItems = computed(() => {
    const items = [...this.baseSidebarItems];

    // Filter out Projects if feature flag is disabled
    if (!this.showProjectsInSidebar()) {
      return items.filter((item) => item.label !== 'Projects');
    }

    return items;
  });
}

Template (main-layout.component.html):

<!-- Desktop Sidebar -->
<div class="hidden lg:block w-64 flex-shrink-0 fixed top-0 left-0">
  <lfx-sidebar [items]="sidebarItems()" [footerItems]="sidebarFooterItems" [showProjectSelector]="true"></lfx-sidebar>
</div>

<!-- Mobile Sidebar -->
<div class="overflow-y-auto h-[calc(100vh-4rem)]">
  <lfx-sidebar [items]="sidebarItems()" [footerItems]="sidebarFooterItems" [showProjectSelector]="true"></lfx-sidebar>
</div>

Use Cases:

  • Phased Rollout: Hide unfinished Projects section until ready
  • Access Control: Show/hide features based on user permissions (via targeting)
  • Feature Gating: Enable premium features only for paid users
  • Maintenance Mode: Temporarily hide sections during maintenance

Benefits:

  • Single computed signal handles filtering logic
  • Multiple flags can filter different items independently
  • Sidebar component receives clean, filtered array
  • No awareness of feature flags in child components

Example 3: Multi-Flag Composition

Combine multiple feature flags with complex logic:

export class DashboardComponent {
  private readonly featureFlagService = inject(FeatureFlagService);

  // Multiple feature flags
  private readonly showAnalytics = this.featureFlagService.getBooleanFlag('analytics-dashboard', false);
  private readonly showReports = this.featureFlagService.getBooleanFlag('reports-section', true);
  private readonly showCharts = this.featureFlagService.getBooleanFlag('interactive-charts', false);

  // Derived flag: analytics requires charts
  protected readonly showFullAnalytics = computed(() => {
    return this.showAnalytics() && this.showCharts();
  });

  // Composite dashboard sections
  protected readonly dashboardSections = computed(() => {
    const sections = [];

    // Always show overview
    sections.push({ id: 'overview', title: 'Overview', component: 'OverviewComponent' });

    // Conditionally add analytics
    if (this.showFullAnalytics()) {
      sections.push({ id: 'analytics', title: 'Analytics', component: 'AnalyticsComponent' });
    }

    // Conditionally add reports
    if (this.showReports()) {
      sections.push({ id: 'reports', title: 'Reports', component: 'ReportsComponent' });
    }

    return sections;
  });

  // Feature configuration object flag
  protected readonly chartConfig = this.featureFlagService.getObjectFlag<{
    type: 'line' | 'bar' | 'pie';
    animated: boolean;
    colors: string[];
  }>('chart-config', {
    type: 'line',
    animated: false,
    colors: ['#3b82f6', '#10b981', '#f59e0b'],
  });
}

Template:

@for (section of dashboardSections(); track section.id) {
<div class="dashboard-section">
  <h2>{{ section.title }}</h2>

  @switch (section.id) { @case ('overview') {
  <app-overview></app-overview>
  } @case ('analytics') {
  <app-analytics [config]="chartConfig()"></app-analytics>
  } @case ('reports') {
  <app-reports></app-reports>
  } }
</div>
}

Use Cases:

  • Feature Dependencies: Only show analytics when charts are enabled
  • Complex Configurations: Use object flags for multi-option settings
  • Dynamic Layouts: Build dashboard sections based on multiple flags
  • Gradual Rollouts: Enable features incrementally with dependent flags

πŸ” Troubleshooting

Flags Not Updating in Real-Time

Symptom: Flag changes in LaunchDarkly dashboard don't reflect in the application

Possible Causes:

  1. Streaming Disabled:

    // Check provider configuration
    streaming: true; // Must be true for real-time updates
  2. Browser Not Connected:

    • Open browser DevTools β†’ Network tab
    • Look for SSE connection to clientstream.launchdarkly.com
    • Should show "pending" status (persistent connection)
  3. Event Handlers Not Set Up:

    // Verify setupEventHandlers() was called
    console.log('Initialized:', this.featureFlagService.initialized());

Solution:

  • Ensure streaming: true in provider configuration
  • Check browser console for connection errors
  • Verify user context was set correctly
  • Test with network throttling disabled

LaunchDarkly Client ID Not Configured

Symptom: Console warning "LaunchDarkly client ID not configured - feature flags disabled"

Possible Causes:

  1. Missing Environment Variable:

    # Check if LD_CLIENT_ID is set
    echo $LD_CLIENT_ID
  2. Local Development - Missing .env File:

    • Create .env file in project root
    • Add LD_CLIENT_ID=your-client-id
  3. Docker - Missing Runtime Variable:

    • Pass -e LD_CLIENT_ID=xxx when running container
  4. TransferState Not Hydrating:

    • Check that provideRuntimeConfig() is before provideFeatureFlags() in app.config.ts
    • Verify server is passing config via REQUEST_CONTEXT

Solution:

  1. Local development: Add LD_CLIENT_ID to .env file
  2. Docker: Pass with -e LD_CLIENT_ID=xxx
  3. Kubernetes: Configure in deployment manifest
  4. Restart development server (yarn dev)

See Runtime Configuration Troubleshooting for more details.

SSR vs Client-Side Initialization Timing

Symptom: Service initialized on server but not client, or vice versa

Possible Causes:

  1. Missing Browser Check:

    // Provider must check for browser environment
    if (typeof window === 'undefined') {
      return;
    }
  2. Initialization Too Early:

    • Service must initialize after authentication
    • User context required for targeting

Solution:

  • Verify browser check in feature-flag.provider.ts
  • Initialize in app.component constructor, not earlier
  • Confirm user object exists before calling initialize()

Context Not Set Properly for Targeting

Symptom: Targeting rules in LaunchDarkly don't work as expected

Possible Causes:

  1. Missing Targeting Key:

    // targetingKey is required for user identification
    targetingKey: user.preferred_username || user.username || user.sub || '';
  2. Incorrect Attribute Names:

    • LaunchDarkly is case-sensitive
    • Custom attributes must match targeting rules exactly

Solution:

  1. Verify targeting key is unique and consistent
  2. Check custom attributes match LaunchDarkly rules
  3. Test targeting rules in LaunchDarkly dashboard
  4. Use LaunchDarkly debugger to see evaluation results

Flags Return Default Values

Symptom: All flags return default values, never true values from LaunchDarkly

Possible Causes:

  1. Service Not Initialized:

    // Check initialization status
    console.log('Initialized:', this.featureFlagService.initialized());
  2. Wrong Flag Key:

    • Flag keys are case-sensitive
    • Must match exactly in LaunchDarkly dashboard
  3. Initialization Failed:

    • Check browser console for errors
    • Network issues during initialization

Solution:

  • Verify initialized() signal returns true
  • Check flag keys match LaunchDarkly exactly
  • Look for initialization errors in console
  • Test with simplified flag (no targeting rules)

Debugging Tips

Enable Debug Logging (Development):

// feature-flag.provider.ts
logger: basicLogger({ level: 'debug' }), // Most verbose

Check Initialization Status:

// In any component
constructor() {
  const flagService = inject(FeatureFlagService);
  console.log('Flag service initialized:', flagService.initialized());

  // Watch for initialization
  effect(() => {
    console.log('Initialized changed:', flagService.initialized());
  });
}

Verify User Context:

// After initialization
const client = OpenFeature.getClient();
const context = await OpenFeature.getContext();
console.log('Current context:', context);

Test Flag Evaluation:

// Manual flag evaluation
const client = OpenFeature.getClient();
const value = client.getBooleanValue('test-flag', false);
console.log('Flag value:', value);

LaunchDarkly Dashboard Debugging:

  1. Navigate to your project in LaunchDarkly
  2. Click on a flag β†’ "Debugger" tab
  3. See real-time evaluation results for each user
  4. Verify targeting rules are evaluating correctly

πŸ”— Related Documentation

External Resources