Skip to content

Commit 4185412

Browse files
committed
feat: enhance feature flag handling with variants and impression data
1 parent 642ee27 commit 4185412

6 files changed

Lines changed: 165 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,30 @@ FeatureFlags.isEnabled(FeatureFlags.QUOTES_SUBMIT)
171171

172172
// Kotlin: error injection flag, default false (opt-in)
173173
FeatureFlags.isEnabled(FeatureFlags.QUOTES_ERRORS, default = false)
174+
175+
// Kotlin: get variant for A/B testing
176+
val variant = FeatureFlags.getVariant(FeatureFlags.QUOTES_SUBMIT)
177+
if (variant.name != "disabled") { /* use variant */ }
174178
```
175179

176180
```typescript
177181
// TypeScript (server-side API routes): check flag
178-
import { isEnabled, FEATURE_FLAGS } from '@/utils/unleash';
182+
import { isEnabled, getVariant, FEATURE_FLAGS } from '@/utils/unleash';
179183
isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT);
184+
185+
// TypeScript: get variant
186+
const variant = getVariant(FEATURE_FLAGS.QUOTES_SUBMIT);
180187
```
181188

189+
**Impression data:**
190+
191+
- Impression data and usage metrics are sent to Unleash automatically by the SDK
192+
- Enable impression data per-toggle in the Unleash admin UI — metrics appear in the Unleash "Metrics" tab
193+
- `GET /api/features` returns `{ "flag": { "enabled": true, "variant": { "name": "..." } } }`
194+
182195
**Rules:**
183196

184197
- Register flag names as constants in `FeatureFlags.kt` / `unleash.ts`
185198
- Define per-flag defaults (e.g., `quotes.submit` defaults `true`, `quotes.errors` defaults `false`)
186-
- Use `GET /api/features` (backend or frontend) to inspect flag states
199+
- Use `GET /api/features` (backend or frontend) to inspect flag states and variants
187200
- Local Unleash admin UI: `http://localhost:4242` (started via `mise run infra:up`)

quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package io.nais.quotesbackend
22

33
import io.getunleash.DefaultUnleash
44
import io.getunleash.Unleash
5+
import io.getunleash.event.UnleashReady
6+
import io.getunleash.event.UnleashSubscriber
57
import io.getunleash.util.UnleashConfig
8+
import io.getunleash.variant.Variant
69
import org.slf4j.LoggerFactory
710

811
object FeatureFlags {
@@ -21,9 +24,17 @@ object FeatureFlags {
2124
try {
2225
val config = UnleashConfig.builder()
2326
.appName(appName)
24-
.unleashAPI("$apiUrl/")
27+
.unleashAPI("$apiUrl/api/")
2528
.apiKey(apiToken)
2629
.environment(environment)
30+
.subscriber(object : UnleashSubscriber {
31+
override fun onReady(ready: UnleashReady) {
32+
log.info("Unleash client ready")
33+
}
34+
override fun onError(unleashException: io.getunleash.UnleashException) {
35+
log.warn("Unleash error: {}", unleashException.message)
36+
}
37+
})
2738
.build()
2839

2940
unleash = DefaultUnleash(config)
@@ -37,13 +48,34 @@ object FeatureFlags {
3748
return unleash?.isEnabled(flag, default) ?: default
3849
}
3950

40-
fun allFlags(): Map<String, Boolean> {
51+
fun getVariant(flag: String): Variant {
52+
return unleash?.getVariant(flag) ?: Variant.DISABLED_VARIANT
53+
}
54+
55+
fun allFlags(): Map<String, Any> {
4156
return mapOf(
42-
QUOTES_SUBMIT to isEnabled(QUOTES_SUBMIT),
43-
QUOTES_ERRORS to isEnabled(QUOTES_ERRORS, default = false),
57+
QUOTES_SUBMIT to flagDetail(QUOTES_SUBMIT),
58+
QUOTES_ERRORS to flagDetail(QUOTES_ERRORS, default = false),
4459
)
4560
}
4661

62+
private fun flagDetail(flag: String, default: Boolean = true): Map<String, Any> {
63+
val enabled = isEnabled(flag, default)
64+
val variant = getVariant(flag)
65+
val detail = mutableMapOf<String, Any>("enabled" to enabled)
66+
if (variant.name != "disabled") {
67+
val variantMap = mutableMapOf<String, Any>(
68+
"name" to variant.name,
69+
"enabled" to variant.isEnabled
70+
)
71+
variant.payload.ifPresent { payload ->
72+
variantMap["payload"] = mapOf("type" to payload.type, "value" to payload.value)
73+
}
74+
detail["variant"] = variantMap
75+
}
76+
return detail
77+
}
78+
4779
fun shutdown() {
4880
unleash?.shutdown()
4981
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
const mockIsEnabled = vi.fn();
4+
const mockGetVariant = vi.fn();
5+
const mockOn = vi.fn();
6+
const mockInitialize = vi.fn(() => ({
7+
isEnabled: mockIsEnabled,
8+
getVariant: mockGetVariant,
9+
on: mockOn,
10+
}));
11+
12+
vi.mock('unleash-client', () => ({
13+
initialize: mockInitialize,
14+
}));
15+
16+
vi.mock('@/utils/logger', () => ({
17+
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
18+
}));
19+
20+
describe('unleash', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.resetModules();
24+
delete process.env.UNLEASH_SERVER_API_URL;
25+
delete process.env.UNLEASH_SERVER_API_TOKEN;
26+
delete process.env.UNLEASH_SERVER_API_ENVIRONMENT;
27+
});
28+
29+
it('returns default when env vars are missing', async () => {
30+
const { isEnabled, FEATURE_FLAGS } = await import('@/utils/unleash');
31+
expect(isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT)).toBe(true);
32+
expect(isEnabled(FEATURE_FLAGS.QUOTES_ERRORS)).toBe(false);
33+
expect(mockInitialize).not.toHaveBeenCalled();
34+
});
35+
36+
it('initializes client and checks flags', async () => {
37+
process.env.UNLEASH_SERVER_API_URL = 'http://unleash';
38+
process.env.UNLEASH_SERVER_API_TOKEN = 'test-token';
39+
mockIsEnabled.mockReturnValue(true);
40+
41+
const { isEnabled, FEATURE_FLAGS } = await import('@/utils/unleash');
42+
const result = isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT);
43+
44+
expect(mockInitialize).toHaveBeenCalledWith(
45+
expect.objectContaining({
46+
url: 'http://unleash/',
47+
appName: 'quotes-frontend',
48+
})
49+
);
50+
expect(result).toBe(true);
51+
});
52+
53+
it('registers lifecycle listeners', async () => {
54+
process.env.UNLEASH_SERVER_API_URL = 'http://unleash';
55+
process.env.UNLEASH_SERVER_API_TOKEN = 'test-token';
56+
mockIsEnabled.mockReturnValue(true);
57+
58+
const { isEnabled, FEATURE_FLAGS } = await import('@/utils/unleash');
59+
isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT);
60+
61+
const registeredEvents = mockOn.mock.calls.map((call: unknown[]) => call[0]);
62+
expect(registeredEvents).not.toContain('impression');
63+
expect(registeredEvents).toContain('ready');
64+
expect(registeredEvents).toContain('error');
65+
});
66+
67+
it('getVariant returns disabled when no client', async () => {
68+
const { getVariant, FEATURE_FLAGS } = await import('@/utils/unleash');
69+
const variant = getVariant(FEATURE_FLAGS.QUOTES_SUBMIT);
70+
expect(variant).toEqual({ name: 'disabled', enabled: false });
71+
});
72+
73+
it('getVariant delegates to client when initialized', async () => {
74+
process.env.UNLEASH_SERVER_API_URL = 'http://unleash';
75+
process.env.UNLEASH_SERVER_API_TOKEN = 'test-token';
76+
mockGetVariant.mockReturnValue({ name: 'variantA', enabled: true, payload: { type: 'string', value: 'hello' } });
77+
mockIsEnabled.mockReturnValue(true);
78+
79+
const { getVariant, isEnabled, FEATURE_FLAGS } = await import('@/utils/unleash');
80+
isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT); // trigger init
81+
const variant = getVariant(FEATURE_FLAGS.QUOTES_SUBMIT);
82+
83+
expect(variant.name).toBe('variantA');
84+
expect(variant.enabled).toBe(true);
85+
});
86+
});
Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { NextResponse } from 'next/server';
2-
import { isEnabled, FEATURE_FLAGS } from '@/utils/unleash';
2+
import { isEnabled, getVariant, FEATURE_FLAGS } from '@/utils/unleash';
33

44
export async function GET() {
55
const features = Object.fromEntries(
6-
Object.entries(FEATURE_FLAGS).map(([, flag]) => [
7-
flag,
8-
isEnabled(flag as (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]),
9-
])
6+
Object.entries(FEATURE_FLAGS).map(([, flag]) => {
7+
const flagKey = flag as (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS];
8+
const variant = getVariant(flagKey);
9+
const detail: Record<string, unknown> = { enabled: isEnabled(flagKey) };
10+
if (variant.name !== 'disabled') {
11+
detail.variant = variant;
12+
}
13+
return [flag, detail];
14+
})
1015
);
1116
return NextResponse.json(features);
1217
}

quotes-frontend/src/app/submit-quote/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export default function SubmitQuote() {
1414
fetch('/api/features')
1515
.then((res) => res.json())
1616
.then((features) => {
17-
const enabled = features['quotes.submit'] ?? true;
17+
const flag = features['quotes.submit'];
18+
const enabled = typeof flag === 'object' ? flag.enabled : (flag ?? true);
1819
setSubmitEnabled(enabled);
1920
if (!enabled) setMessage("Submitting new quotes is currently disabled.");
2021
})

quotes-frontend/src/utils/unleash.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { initialize, Unleash } from 'unleash-client';
2+
import logger from '@/utils/logger';
23

34
let unleash: Unleash | null = null;
45

@@ -10,12 +11,20 @@ export function getUnleash(): Unleash | null {
1011
if (!url || !token) return null;
1112

1213
unleash = initialize({
13-
url: `${url}/`,
14+
url: `${url}/api/`,
1415
appName: 'quotes-frontend',
1516
customHeaders: { Authorization: token },
1617
environment: process.env.UNLEASH_SERVER_API_ENVIRONMENT || 'development',
1718
});
1819

20+
unleash.on('ready', () => {
21+
logger.info('Unleash client ready');
22+
});
23+
24+
unleash.on('error', (err: Error) => {
25+
logger.warn({ err: err.message }, 'Unleash error');
26+
});
27+
1928
return unleash;
2029
}
2130

@@ -39,3 +48,9 @@ export function isEnabled(flag: FeatureFlag, defaultValue?: boolean): boolean {
3948
if (!client) return effectiveDefault;
4049
return client.isEnabled(flag, undefined, effectiveDefault);
4150
}
51+
52+
export function getVariant(flag: FeatureFlag) {
53+
const client = getUnleash();
54+
if (!client) return { name: 'disabled', enabled: false };
55+
return client.getVariant(flag);
56+
}

0 commit comments

Comments
 (0)