Skip to content

Commit 1eaaf10

Browse files
committed
Merge main into FFL-2835 configuration parsing
2 parents 03cde21 + 2e1d524 commit 1eaaf10

13 files changed

Lines changed: 705 additions & 72 deletions

File tree

packages/browser/src/domain/configuration.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Configuration, EndpointBuilder, InitConfiguration } from '@datadog/browser-core'
2-
import { display, validateAndBuildConfiguration } from '@datadog/browser-core'
2+
import { validateAndBuildConfiguration } from '@datadog/browser-core'
33
import type { FlagsConfiguration } from '@datadog/flagging-core'
44
import type { EvaluationContext } from '@openfeature/web-sdk'
55
import type { DDRum } from '../openfeature/rumIntegration'
@@ -90,11 +90,6 @@ export interface FlaggingConfiguration extends Configuration {
9090
export function validateAndBuildFlaggingConfiguration(
9191
initConfiguration: FlaggingInitConfiguration
9292
): FlaggingConfiguration | undefined {
93-
if (!initConfiguration.applicationId) {
94-
display.error('Application ID is not configured, no flagging data will be collected.')
95-
return
96-
}
97-
9893
const baseConfiguration = validateAndBuildConfiguration(initConfiguration)
9994
if (!baseConfiguration) {
10095
return

packages/browser/test/openfeature/exposures.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,39 @@ describe('Exposures End-to-End', () => {
298298
})
299299
})
300300

301+
it('should send exposure events without RUM application attribution when applicationId is not provided', async () => {
302+
fetchMock.mockImplementation((url: string) => {
303+
if (url.includes('exposures')) {
304+
return Promise.resolve({ ok: true, status: 200 })
305+
}
306+
if (url.includes('precompute-assignments')) {
307+
return Promise.resolve({
308+
ok: true,
309+
json: () => Promise.resolve(precomputedServerResponse),
310+
})
311+
}
312+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
313+
})
314+
315+
await OpenFeature.setContext({ targetingKey: 'test-user-123' })
316+
const provider = new DatadogProvider({
317+
clientToken: 'test-client-token',
318+
env: 'test',
319+
site: INTAKE_SITE_STAGING,
320+
enableExposureLogging: true,
321+
})
322+
await OpenFeature.setProviderAndWait(provider)
323+
324+
OpenFeature.getClient().getStringValue('string-flag', 'default')
325+
triggerBatch()
326+
327+
const exposuresCalls = getExposuresCalls()
328+
expect(exposuresCalls).toHaveLength(1)
329+
330+
const [event] = parseExposureEvents(exposuresCalls[0][1].body)
331+
expect(event.rum).toEqual({ view: { url: 'http://localhost/' } })
332+
})
333+
301334
it('should not send exposure events when exposure logging is disabled', async () => {
302335
// Mock server response
303336
fetchMock.mockImplementation((url: string) => {

packages/browser/test/openfeature/provider.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,31 @@ describe('DatadogProvider', () => {
517517
})
518518
})
519519

520+
describe('initialization without applicationId', () => {
521+
it('should initialize successfully', async () => {
522+
const originalFetch = global.fetch
523+
global.fetch = jest.fn().mockResolvedValue({
524+
ok: true,
525+
json: async () => precomputedResponse,
526+
})
527+
const testProvider = new DatadogProvider({
528+
clientToken: 'xxx',
529+
env: 'test',
530+
site: INTAKE_SITE_STAGING,
531+
enableExposureLogging: false,
532+
enableFlagEvaluationTracking: false,
533+
enableRumFeatureFlagTracking: false,
534+
})
535+
536+
try {
537+
await expect(testProvider.initialize()).resolves.toBeUndefined()
538+
expect(testProvider.status).toBe(ProviderStatus.READY)
539+
} finally {
540+
global.fetch = originalFetch
541+
}
542+
})
543+
})
544+
520545
describe('error handling integration', () => {
521546
let originalFetch: (input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>
522547
let isolatedFetchMock: jest.Mock

packages/core/src/evaluation/evaluateForSubject.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type TimeStamp, timeStampNow } from '../time'
44
import { TargetingKeyMissingError } from './errors'
55
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
66
import { matchesShard } from './matchesShard'
7-
import { isValidRule, matchesRule, type Rule } from './rules'
7+
import { hasInvalidSemverComparand, isValidRule, matchesRule, type Rule } from './rules'
88
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'
99

1010
export function evaluateForSubject<T extends FlagValueType>(
@@ -157,15 +157,25 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType
157157
}
158158

159159
function isValidFlag(flag: Flag): boolean {
160-
return (
161-
Array.isArray(flag.allocations) &&
162-
flag.allocations.every(
160+
if (!Array.isArray(flag.allocations)) {
161+
return false
162+
}
163+
164+
if (
165+
flag.allocations.some(
163166
(allocation) =>
164-
Array.isArray(allocation.splits) &&
165-
allocation.splits.every((split) => Array.isArray(split.shards)) &&
166-
(allocation.rules === undefined ||
167-
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
167+
Array.isArray(allocation.rules) && allocation.rules.some((rule) => hasInvalidSemverComparand(rule))
168168
)
169+
) {
170+
throw new Error('invalid semantic version comparand')
171+
}
172+
173+
return flag.allocations.every(
174+
(allocation) =>
175+
Array.isArray(allocation.splits) &&
176+
allocation.splits.every((split) => Array.isArray(split.shards)) &&
177+
(allocation.rules === undefined ||
178+
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
169179
)
170180
}
171181

packages/core/src/evaluation/rules.test.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,5 @@
11
import { isValidRule, matchesRule, OperatorType, type Rule } from './rules'
22

3-
describe('SemVer conditions', () => {
4-
it.each([
5-
[OperatorType.SEMVER_EQUAL, '1.2.3+build.9', '1.2.3+other', true],
6-
[OperatorType.SEMVER_NOT_EQUAL, '1.2.3-alpha', '1.2.3', true],
7-
[OperatorType.SEMVER_LT, '1.2.3-alpha.2', '1.2.3-alpha.10', true],
8-
[OperatorType.SEMVER_LTE, '1.2.3', '1.2.3', true],
9-
[OperatorType.SEMVER_GT, '2.0.0', '1.999999999999999999999.0', true],
10-
[OperatorType.SEMVER_GTE, '1.2.3', '2.0.0', false],
11-
] as const)('%s compares %s against %s', (operator, actual, expected, matches) => {
12-
expect(
13-
matchesRule({ conditions: [{ operator, attribute: 'version', value: expected }] }, { version: actual })
14-
).toBe(matches)
15-
})
16-
17-
it.each(['1.2', '01.2.3', '1.2.3-01', '1.2.3+'])('rejects invalid strict SemVer value %s', (value) => {
18-
const rule: Rule = {
19-
conditions: [{ operator: OperatorType.SEMVER_EQUAL, attribute: 'version', value: '1.2.3' }],
20-
}
21-
22-
expect(matchesRule(rule, { version: value })).toBe(false)
23-
})
24-
})
25-
263
describe('SHA-256 membership conditions', () => {
274
const hash = 'c0e551d80aa1e2cb1eaf5be7edbb04e51eb1823e562e2ce5dfeda0ecba76c744'
285

packages/core/src/evaluation/rules.ts

Lines changed: 74 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { EvaluationContext, EvaluationContextValue } from '@openfeature/core'
22
import { encodeUtf8 } from '../utf8'
3-
import { compareSemver, compileRegex, isValidSemver } from './condition-helpers'
3+
import { compileRegex } from './condition-helpers'
4+
import { compareSemver, parseSemver } from './semver'
45
import { sha256Hex } from './sha256'
56

67
export type ConditionValueType = EvaluationContextValue | EvaluationContextValue[]
@@ -17,8 +18,8 @@ export enum OperatorType {
1718
ONE_OF_SHA256 = 'ONE_OF_SHA256',
1819
NOT_ONE_OF_SHA256 = 'NOT_ONE_OF_SHA256',
1920
IS_NULL = 'IS_NULL',
20-
SEMVER_EQUAL = 'SEMVER_EQUAL',
21-
SEMVER_NOT_EQUAL = 'SEMVER_NOT_EQUAL',
21+
SEMVER_EQ = 'SEMVER_EQ',
22+
SEMVER_NEQ = 'SEMVER_NEQ',
2223
SEMVER_LT = 'SEMVER_LT',
2324
SEMVER_LTE = 'SEMVER_LTE',
2425
SEMVER_GT = 'SEMVER_GT',
@@ -74,14 +75,16 @@ type Sha256Condition = {
7475
}
7576
}
7677

78+
type SemverOperator =
79+
| OperatorType.SEMVER_EQ
80+
| OperatorType.SEMVER_NEQ
81+
| OperatorType.SEMVER_LT
82+
| OperatorType.SEMVER_LTE
83+
| OperatorType.SEMVER_GT
84+
| OperatorType.SEMVER_GTE
85+
7786
type SemverCondition = {
78-
operator:
79-
| OperatorType.SEMVER_EQUAL
80-
| OperatorType.SEMVER_NOT_EQUAL
81-
| OperatorType.SEMVER_LT
82-
| OperatorType.SEMVER_LTE
83-
| OperatorType.SEMVER_GT
84-
| OperatorType.SEMVER_GTE
87+
operator: SemverOperator
8588
attribute: string
8689
value: string
8790
}
@@ -109,13 +112,8 @@ export function isValidRule(rule: Rule): boolean {
109112
if (!supportedOperators.has(condition.operator)) {
110113
return false
111114
}
112-
if (condition.operator === OperatorType.MATCHES || condition.operator === OperatorType.NOT_MATCHES) {
113-
try {
114-
compileRegex(condition.value)
115-
return true
116-
} catch {
117-
return false
118-
}
115+
if (isSemverOperator(condition.operator)) {
116+
return parseSemver(condition.value) !== null
119117
}
120118
if (condition.operator === OperatorType.ONE_OF_SHA256 || condition.operator === OperatorType.NOT_ONE_OF_SHA256) {
121119
return (
@@ -124,8 +122,13 @@ export function isValidRule(rule: Rule): boolean {
124122
condition.value.hashes.every((hash) => /^[0-9a-f]{64}$/.test(hash))
125123
)
126124
}
127-
if (condition.operator.startsWith('SEMVER_')) {
128-
return isValidSemver(condition.value as string)
125+
if (condition.operator === OperatorType.MATCHES || condition.operator === OperatorType.NOT_MATCHES) {
126+
try {
127+
compileRegex(condition.value)
128+
return true
129+
} catch {
130+
return false
131+
}
129132
}
130133
return true
131134
})
@@ -185,26 +188,66 @@ function evaluateCondition(subjectAttributes: EvaluationContext, condition: Cond
185188
const included = condition.value.hashes.includes(sha256Hex(input))
186189
return condition.operator === OperatorType.ONE_OF_SHA256 ? included : !included
187190
}
188-
case OperatorType.SEMVER_EQUAL:
189-
case OperatorType.SEMVER_NOT_EQUAL:
191+
case OperatorType.SEMVER_EQ:
192+
case OperatorType.SEMVER_NEQ:
190193
case OperatorType.SEMVER_LT:
191194
case OperatorType.SEMVER_LTE:
192195
case OperatorType.SEMVER_GT:
193-
case OperatorType.SEMVER_GTE: {
194-
const comparison = compareSemver(String(value), condition.value)
195-
if (comparison === undefined) return false
196-
if (condition.operator === OperatorType.SEMVER_EQUAL) return comparison === 0
197-
if (condition.operator === OperatorType.SEMVER_NOT_EQUAL) return comparison !== 0
198-
if (condition.operator === OperatorType.SEMVER_LT) return comparison < 0
199-
if (condition.operator === OperatorType.SEMVER_LTE) return comparison <= 0
200-
if (condition.operator === OperatorType.SEMVER_GT) return comparison > 0
201-
return comparison >= 0
202-
}
196+
case OperatorType.SEMVER_GTE:
197+
return evaluateSemverCondition(value, condition.value, condition.operator)
203198
}
204199
}
205200
return false
206201
}
207202

203+
export function isSemverOperator(operator: string): operator is SemverOperator {
204+
return (
205+
operator === OperatorType.SEMVER_EQ ||
206+
operator === OperatorType.SEMVER_NEQ ||
207+
operator === OperatorType.SEMVER_LT ||
208+
operator === OperatorType.SEMVER_LTE ||
209+
operator === OperatorType.SEMVER_GT ||
210+
operator === OperatorType.SEMVER_GTE
211+
)
212+
}
213+
214+
export function hasInvalidSemverComparand(rule: Rule): boolean {
215+
return rule.conditions.some(
216+
(condition) => isSemverOperator(condition.operator) && parseSemver(condition.value) === null
217+
)
218+
}
219+
220+
function evaluateSemverCondition(
221+
attributeValue: EvaluationContextValue,
222+
comparandValue: string,
223+
operator: SemverOperator
224+
): boolean {
225+
if (typeof attributeValue !== 'string') {
226+
return false
227+
}
228+
229+
const attribute = parseSemver(attributeValue)
230+
const comparand = parseSemver(comparandValue)
231+
if (!attribute || !comparand) {
232+
return false
233+
}
234+
235+
const ordering = compareSemver(attribute, comparand)
236+
switch (operator) {
237+
case OperatorType.SEMVER_EQ:
238+
return ordering === 0
239+
case OperatorType.SEMVER_NEQ:
240+
return ordering !== 0
241+
case OperatorType.SEMVER_LT:
242+
return ordering < 0
243+
case OperatorType.SEMVER_LTE:
244+
return ordering <= 0
245+
case OperatorType.SEMVER_GT:
246+
return ordering > 0
247+
case OperatorType.SEMVER_GTE:
248+
return ordering >= 0
249+
}
250+
}
208251
function isOneOf(attributeValue: string, conditionValues: string[]) {
209252
return conditionValues.includes(attributeValue)
210253
}

0 commit comments

Comments
 (0)