Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions packages/datadog_flags/lib/src/evaluation_aggregator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import 'dart:async';
import 'dart:convert';

import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';

import 'assignment.dart';
import 'evaluation_context.dart';
import 'flags_runtime.dart';
import 'intake_request.dart';
import 'json_value.dart';
import 'sdk_metadata.dart';

class EvaluationAggregator {
static const int defaultMaxBatchSize = 1000;
Expand Down Expand Up @@ -172,24 +171,23 @@ class EvaluationAggregator {
List<_AggregatedEvaluation> evaluations, {
required bool rescheduleOnFailure,
}) async {
final endpoint = _evaluationEndpoint();
final body = jsonEncode({
'context': _datadogContext(),
'flagEvaluations': evaluations.map((e) => e.toJson()).toList(),
});
final request = buildFlagsIntakeRequest(
endpoint: _evaluationEndpoint(),
clientToken: runtime.datadogConfig.clientToken,
nativeContentType: 'application/json',
nativeBodyBuilder: () => jsonEncode({
'context': _datadogContext(),
'flagEvaluations': evaluations.map((e) => e.toJson()).toList(),
}),
webBodyBuilder: () => evaluations.map(_webEvaluationJson).join('\n'),
);

try {
final response = await runtime.httpClient
.post(
endpoint,
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': runtime.datadogConfig.clientToken,
'DD-EVP-ORIGIN': datadogFlagsSource,
'DD-EVP-ORIGIN-VERSION': datadogFlagsSdkVersion,
'DD-REQUEST-ID': const Uuid().v4(),
},
body: body,
request.endpoint,
headers: request.headers,
body: request.body,
)
.timeout(uploadTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
Expand Down Expand Up @@ -239,14 +237,22 @@ class EvaluationAggregator {

Uri _evaluationEndpoint() {
final datadogConfig = runtime.datadogConfig;
final endpoint = runtime.configuration.customEvaluationEndpoint ??
return runtime.configuration.customEvaluationEndpoint ??
datadogConfig.intakeEndpoint().replace(path: '/api/v2/flagevaluation');
return endpoint.replace(
queryParameters: {
...endpoint.queryParameters,
'ddsource': datadogFlagsSource,
},
);
}

String _webEvaluationJson(_AggregatedEvaluation evaluation) {
final event = evaluation.toJson();
final eventContext = switch (event['context']) {
Map<String, Object?> context => Map<String, Object?>.from(context),
_ => <String, Object?>{},
};
final datadogContext = _datadogContext();
if (datadogContext.isNotEmpty) {
eventContext['dd'] = datadogContext;
event['context'] = eventContext;
}
return jsonEncode(event);
}

Map<String, Object?> _datadogContext() {
Expand Down
31 changes: 13 additions & 18 deletions packages/datadog_flags/lib/src/exposure_logger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import 'dart:async';
import 'dart:convert';

import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';

import 'assignment.dart';
import 'evaluation_context.dart';
import 'flags_runtime.dart';
import 'intake_request.dart';
import 'json_value.dart';
import 'sdk_metadata.dart';

class ExposureLogger {
static const Duration defaultUploadTimeout = Duration(seconds: 15);
Expand Down Expand Up @@ -145,18 +144,20 @@ class ExposureLogger {
List<Map<String, Object?>> exposures, {
required bool rescheduleOnFailure,
}) async {
final body = exposures.map(jsonEncode).join('\n');
final request = buildFlagsIntakeRequest(
endpoint: _exposureEndpoint(),
clientToken: runtime.datadogConfig.clientToken,
nativeContentType: 'text/plain;charset=UTF-8',
nativeBodyBuilder: () => body,
webBodyBuilder: () => body,
);
try {
final response = await runtime.httpClient
.post(
_exposureEndpoint(),
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
'DD-API-KEY': runtime.datadogConfig.clientToken,
'DD-EVP-ORIGIN': datadogFlagsSource,
'DD-EVP-ORIGIN-VERSION': datadogFlagsSdkVersion,
'DD-REQUEST-ID': const Uuid().v4(),
},
body: exposures.map(jsonEncode).join('\n'),
request.endpoint,
headers: request.headers,
body: request.body,
)
.timeout(uploadTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
Expand Down Expand Up @@ -224,14 +225,8 @@ class ExposureLogger {

Uri _exposureEndpoint() {
final datadogConfig = runtime.datadogConfig;
final endpoint = runtime.configuration.customExposureEndpoint ??
return runtime.configuration.customExposureEndpoint ??
datadogConfig.intakeEndpoint().replace(path: '/api/v2/exposures');
return endpoint.replace(
queryParameters: {
...endpoint.queryParameters,
'ddsource': datadogFlagsSource,
},
);
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/datadog_flags/lib/src/intake_platform.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0. This product includes software
// developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

export 'intake_platform_web.dart'
if (dart.library.io) 'intake_platform_native.dart';
6 changes: 6 additions & 0 deletions packages/datadog_flags/lib/src/intake_platform_native.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0. This product includes software
// developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

const isWebFlagsIntake = false;
6 changes: 6 additions & 0 deletions packages/datadog_flags/lib/src/intake_platform_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0. This product includes software
// developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

const isWebFlagsIntake = true;
65 changes: 65 additions & 0 deletions packages/datadog_flags/lib/src/intake_request.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0. This product includes software
// developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';

import 'intake_platform.dart';
import 'sdk_metadata.dart';

@immutable
final class FlagsIntakeRequest {
final Uri endpoint;
final Map<String, String> headers;
final String body;

const FlagsIntakeRequest({
required this.endpoint,
required this.headers,
required this.body,
});
}

FlagsIntakeRequest buildFlagsIntakeRequest({
required Uri endpoint,
required String clientToken,
required String nativeContentType,
required String Function() nativeBodyBuilder,
required String Function() webBodyBuilder,
}) {
final requestId = const Uuid().v4();
final queryParameters = {
...endpoint.queryParameters,
'ddsource': datadogFlagsSource,
if (isWebFlagsIntake) ...{
'dd-api-key': clientToken,
'dd-evp-origin-version': datadogFlagsSdkVersion,
'dd-evp-origin': datadogFlagsSource,
'dd-request-id': requestId,
},
};

if (isWebFlagsIntake) {
// Match the Browser SDK transport so the browser does not send an OPTIONS
// CORS preflight before each intake request.
return FlagsIntakeRequest(
endpoint: endpoint.replace(queryParameters: queryParameters),
headers: const {'Content-Type': 'text/plain'},
body: webBodyBuilder(),
);
}

return FlagsIntakeRequest(
endpoint: endpoint.replace(queryParameters: queryParameters),
headers: {
'Content-Type': nativeContentType,
'DD-API-KEY': clientToken,
'DD-EVP-ORIGIN': datadogFlagsSource,
'DD-EVP-ORIGIN-VERSION': datadogFlagsSdkVersion,
'DD-REQUEST-ID': requestId,
},
body: nativeBodyBuilder(),
);
}
96 changes: 71 additions & 25 deletions packages/datadog_flags/test/datadog_flags_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:datadog_flags/datadog_flags.dart';
import 'package:datadog_flags/src/evaluation_aggregator.dart';
import 'package:datadog_flags/src/exposure_logger.dart';
import 'package:datadog_flags/src/flags_store.dart';
import 'package:datadog_flags/src/intake_platform.dart';
import 'package:datadog_flags/src/sdk_metadata.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
Expand Down Expand Up @@ -339,14 +340,13 @@ void main() {
expect(_exposureRequests(requests), hasLength(1));
final request = _exposureRequests(requests).single;
expect(
request.url.toString(),
'https://browser-intake-datadoghq.com/api/v2/exposures?ddsource=dart-client',
'${request.url.origin}${request.url.path}',
'https://browser-intake-datadoghq.com/api/v2/exposures',
);
_expectIntakeMetadata(
request,
nativeContentType: 'text/plain;charset=UTF-8',
);
expect(request.headers['Content-Type'], 'text/plain;charset=UTF-8');
expect(request.headers['DD-API-KEY'], 'client-token');
expect(request.headers['DD-EVP-ORIGIN'], 'dart-client');
expect(request.headers['DD-EVP-ORIGIN-VERSION'], datadogFlagsSdkVersion);
expect(request.headers['DD-REQUEST-ID'], isNotEmpty);

final exposure = _exposureEvents(request).single;
expect(exposure, {
Expand Down Expand Up @@ -750,20 +750,17 @@ void main() {

final request = _evaluationRequests(requests).single;
expect(
request.url.toString(),
'https://browser-intake-datadoghq.com/api/v2/flagevaluation?ddsource=dart-client',
'${request.url.origin}${request.url.path}',
'https://browser-intake-datadoghq.com/api/v2/flagevaluation',
);
expect(request.headers['Content-Type'], 'application/json');
expect(request.headers['DD-API-KEY'], 'client-token');
expect(request.headers['DD-EVP-ORIGIN'], 'dart-client');
expect(request.headers['DD-EVP-ORIGIN-VERSION'], datadogFlagsSdkVersion);
expect(request.headers['DD-REQUEST-ID'], isNotEmpty);
_expectIntakeMetadata(request, nativeContentType: 'application/json');

final body = jsonDecode(request.body) as Map<String, Object?>;
expect(body['context'], _datadogEventContext());
if (!isWebFlagsIntake) {
final body = jsonDecode(request.body) as Map<String, Object?>;
expect(body['context'], _datadogEventContext());
}

final evaluations = (body['flagEvaluations'] as List<Object?>)
.cast<Map<String, Object?>>();
final evaluations = _flagEvaluationEvents(request);
expect(evaluations, hasLength(3));

final success = _evaluation(
Expand All @@ -780,6 +777,15 @@ void main() {
expect(success['targeting_key'], 'user-123');
expect(success['context'], {
'evaluation': {'plan': 'pro'},
if (isWebFlagsIntake)
'dd': {
'env': 'staging',
'service': 'shopping-cart',
'version': '1.2.3',
'rum': {
'application': {'id': 'application-id'},
},
},
});
expect(success.containsKey('runtime_default_used'), isFalse);

Expand Down Expand Up @@ -821,9 +827,7 @@ void main() {
await client.shutdown();

final request = _evaluationRequests(requests).single;
final body = jsonDecode(request.body) as Map<String, Object?>;
final evaluations =
(body['flagEvaluations'] as List<Object?>).cast<Map<String, Object?>>();
final evaluations = _flagEvaluationEvents(request);

final evaluation = evaluations.single;
expect(evaluation['flag'], {'key': 'show-paywall'});
Expand Down Expand Up @@ -985,10 +989,8 @@ void main() {
await client.shutdown();

expect(_evaluationRequests(requests), hasLength(2));
final secondBody = jsonDecode(_evaluationRequests(requests).last.body)
as Map<String, Object?>;
final secondEvaluations = (secondBody['flagEvaluations'] as List<Object?>)
.cast<Map<String, Object?>>();
final secondEvaluations =
_flagEvaluationEvents(_evaluationRequests(requests).last);
expect(secondEvaluations.single['flag'], {'key': 'theme'});
});

Expand Down Expand Up @@ -1520,6 +1522,50 @@ List<Map<String, Object?>> _exposureEvents(http.Request request) {
.toList();
}

List<Map<String, Object?>> _flagEvaluationEvents(http.Request request) {
if (isWebFlagsIntake) {
return request.body
.split('\n')
.where((line) => line.isNotEmpty)
.map((line) => jsonDecode(line) as Map<String, Object?>)
.toList();
}
final body = jsonDecode(request.body) as Map<String, Object?>;
return (body['flagEvaluations'] as List<Object?>)
.cast<Map<String, Object?>>();
}

void _expectIntakeMetadata(
http.Request request, {
required String nativeContentType,
}) {
expect(request.url.queryParameters['ddsource'], datadogFlagsSource);
if (isWebFlagsIntake) {
expect(request.headers['Content-Type'], startsWith('text/plain'));
expect(request.headers['DD-API-KEY'], isNull);
expect(request.headers['DD-EVP-ORIGIN'], isNull);
expect(request.headers['DD-EVP-ORIGIN-VERSION'], isNull);
expect(request.headers['DD-REQUEST-ID'], isNull);
expect(request.url.queryParameters['dd-api-key'], 'client-token');
expect(request.url.queryParameters['dd-evp-origin'], datadogFlagsSource);
expect(
request.url.queryParameters['dd-evp-origin-version'],
datadogFlagsSdkVersion,
);
expect(request.url.queryParameters['dd-request-id'], isNotEmpty);
return;
}

expect(request.headers['Content-Type'], nativeContentType);
expect(request.headers['DD-API-KEY'], 'client-token');
expect(request.headers['DD-EVP-ORIGIN'], datadogFlagsSource);
expect(
request.headers['DD-EVP-ORIGIN-VERSION'],
datadogFlagsSdkVersion,
);
expect(request.headers['DD-REQUEST-ID'], isNotEmpty);
}

Map<String, Object?> _datadogEventContext() {
return {
'env': 'staging',
Expand Down
Loading