Skip to content

Commit 9aa3c7d

Browse files
test(ffe): enable Node.js agentless flag evaluation (#7355)
Co-authored-by: leo.romanovsky <leo.romanovsky@datadoghq.com>
1 parent d56b98b commit 9aa3c7d

30 files changed

Lines changed: 467 additions & 47 deletions

manifests/nodejs.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ refs:
103103
- &ref_6_0_0 '>=6.0.0-pre'
104104
- &ref_6_5_0 '>=6.5.0 || ^5.116.0'
105105
- &ref_6_7_0 '>=6.7.0 || ^5.118.0'
106+
- &ref_6_8_0 '>=6.8.0 || ^5.119.0'
106107
manifest:
107108
tests/ai_guard/test_ai_guard_sdk.py::Test_AIGuardEvent_Tag:
108109
- weblog_declaration:
@@ -1715,7 +1716,7 @@ manifest:
17151716
- declaration: missing_feature (Not implemented yet)
17161717
component_version: <5.66.0
17171718
tests/docker_ssi/test_docker_ssi_appsec.py::TestDockerSSIAppsecFeatures::test_telemetry_source_ssi: *ref_5_83_0
1718-
tests/ffe/test_agentless_configuration.py: missing_feature (FFL-2697 tracks Node.js agentless configuration-source implementation; FFL-2731 tracks the system-tests contract)
1719+
tests/ffe/test_agentless_configuration.py: *ref_6_8_0
17191720
tests/ffe/test_dynamic_evaluation.py:
17201721
- weblog_declaration:
17211722
"*": incomplete_test_app
@@ -2078,7 +2079,7 @@ manifest:
20782079
tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart::test_baggage: incomplete_test_app (The parametric test app does not preserve baggage after extraction)
20792080
tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First: *ref_5_47_0
20802081
tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First::test_baggage: incomplete_test_app (The parametric test app does not preserve baggage after extraction)
2081-
tests/parametric/test_ffe/test_configuration_sources.py: missing_feature (FFL-2697 tracks Node.js agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract)
2082+
tests/parametric/test_ffe/test_configuration_sources.py: *ref_6_8_0
20822083
tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: *ref_5_75_0
20832084
tests/parametric/test_ffe/test_span_enrichment.py: "missing_feature (dd-trace-js#8343)"
20842085
tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_invalid: missing_feature (Need to remove b3=b3multi alias)

tests/ffe/test_agentless_configuration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,3 @@ def test_default_agentless_source(self) -> None:
2929
assert backend_status is not None
3030
assert backend_status["requests_total"] >= 1
3131
assert backend_status["last_path"] == CONFIG_PATH
32-
assert backend_status["last_auth_present"] is True

tests/test_the_test/test_docker_scenario.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,17 @@ def __init__(self) -> None:
107107

108108

109109
@scenarios.test_the_test
110-
@pytest.mark.parametrize(("is_empty_test_run", "flush"), [(True, False), (False, True)])
111-
def test_end_to_end_scenario_only_flushes_non_empty_test_runs(
112-
monkeypatch: pytest.MonkeyPatch, *, is_empty_test_run: bool, flush: bool
110+
@pytest.mark.parametrize(
111+
("include_agent", "is_empty_test_run", "flush"),
112+
[(True, True, False), (True, False, True), (False, True, False), (False, False, False)],
113+
)
114+
def test_end_to_end_scenario_only_flushes_agent_backed_non_empty_test_runs(
115+
monkeypatch: pytest.MonkeyPatch, *, include_agent: bool, is_empty_test_run: bool, flush: bool
113116
) -> None:
114117
scenario = DdTraceEndToEndScenario(
115118
"FAKE_END_TO_END",
116119
doc="",
117-
include_agent=False,
120+
include_agent=include_agent,
118121
use_proxy_for_agent=False,
119122
use_proxy_for_weblog=False,
120123
)

tests/test_the_test/test_mock_ffe_agentless_backend.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit coverage for the mock FFE agentless backend test fixture."""
22

3+
from pathlib import Path
34
from unittest.mock import MagicMock
45

56
import requests
@@ -155,3 +156,41 @@ def test_agentless_end_to_end_scenario_closes_backend_when_status_fails() -> Non
155156

156157
backend.close.assert_called_once_with()
157158
assert scenario._mock_backend is None # noqa: SLF001 - focused lifecycle test
159+
160+
161+
@scenarios.test_the_test
162+
def test_agentless_end_to_end_scenario_persists_backend_status_for_replay(
163+
monkeypatch: pytest.MonkeyPatch,
164+
tmp_path: Path,
165+
) -> None:
166+
monkeypatch.chdir(tmp_path)
167+
168+
recording_scenario = FeatureFlaggingAgentlessEndToEndScenario("MOCK_FFE_AGENTLESS_REPLAY", doc="test")
169+
recording_scenario._mock_backend_status_path.parent.mkdir() # noqa: SLF001 - focused lifecycle test
170+
171+
expected_status = {
172+
"requests_total": 1,
173+
"in_flight": 0,
174+
"max_in_flight": 1,
175+
"last_path": CONFIG_PATH,
176+
"last_if_none_match": None,
177+
"last_auth_present": True,
178+
"last_status_code": 200,
179+
"status_codes": [200],
180+
}
181+
backend = MagicMock(spec=MockFFEAgentlessBackendServer)
182+
backend.status.return_value = expected_status
183+
recording_scenario._mock_backend = backend # noqa: SLF001 - focused lifecycle test
184+
185+
recording_scenario._stop_mock_backend() # noqa: SLF001 - focused lifecycle test
186+
187+
replay_scenario = FeatureFlaggingAgentlessEndToEndScenario("MOCK_FFE_AGENTLESS_REPLAY", doc="test")
188+
replay_scenario.replay = True
189+
base_configure = MagicMock()
190+
monkeypatch.setattr(endtoend_scenarios.DdTraceEndToEndScenario, "configure", base_configure)
191+
config = MagicMock(spec=pytest.Config)
192+
replay_scenario.configure(config)
193+
194+
backend.close.assert_called_once_with()
195+
base_configure.assert_called_once_with(config)
196+
assert replay_scenario.mock_backend_status() == expected_status

utils/_context/_scenarios/endtoend.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
from typing import Literal
1+
import json
22
import os
3+
from pathlib import Path
4+
from typing import Literal, cast
5+
36
import pytest
47

58
from docker.models.networks import Network
@@ -466,9 +469,9 @@ def _wait_and_stop_containers(self, *, is_empty_test_run: bool):
466469
else:
467470
self._wait_interface(interfaces.library, 0 if is_empty_test_run else self.library_interface_timeout)
468471

469-
# An empty selection has no test-generated data to flush. This also avoids waiting on
470-
# Agent-backed writers in scenarios that intentionally do not start an Agent.
471-
self.weblog_infra.stop(flush=not is_empty_test_run)
472+
# An empty selection has no test-generated data to flush. An Agentless scenario also
473+
# has no Agent-backed writer target, so its flush endpoint can only time out.
474+
self.weblog_infra.stop(flush=not is_empty_test_run and self.include_agent)
472475
interfaces.library.check_deserialization_errors()
473476

474477
for container in self.buddies:
@@ -615,6 +618,7 @@ class FeatureFlaggingAgentlessEndToEndScenario(DdTraceEndToEndScenario):
615618
"""FFE end-to-end scenario with UFC available before the weblog starts."""
616619

617620
_default_scenario_groups: tuple[ScenarioGroup, ...] = ()
621+
_mock_backend_status_filename = "mock_ffe_agentless_backend_status.json"
618622

619623
_mock_backend: MockFFEAgentlessBackendServer | None = None
620624
_last_mock_backend_status: MockFFEAgentlessBackendStatus | None = None
@@ -627,7 +631,6 @@ def __init__(
627631
weblog_env: dict[str, str | None] | None = None,
628632
) -> None:
629633
environment: dict[str, str | None] = {
630-
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "true",
631634
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "0.2",
632635
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": "2",
633636
"DD_REMOTE_CONFIGURATION_ENABLED": "false",
@@ -647,12 +650,15 @@ def __init__(
647650

648651
def configure(self, config: pytest.Config) -> None:
649652
try:
650-
if not self.replay:
653+
if self.replay:
654+
self._load_mock_backend_status()
655+
else:
656+
self._last_mock_backend_status = None
651657
self._start_mock_backend()
652658

653659
super().configure(config)
654660
except BaseException:
655-
self._stop_mock_backend()
661+
self._stop_mock_backend(persist_status=False)
656662
raise
657663

658664
def _start_mock_backend(self) -> None:
@@ -673,14 +679,30 @@ def mock_backend_status(self) -> MockFFEAgentlessBackendStatus | None:
673679
return self._mock_backend.status()
674680
return self._last_mock_backend_status
675681

676-
def _stop_mock_backend(self) -> None:
682+
@property
683+
def _mock_backend_status_path(self) -> Path:
684+
return Path(self.host_log_folder) / self._mock_backend_status_filename
685+
686+
def _load_mock_backend_status(self) -> None:
687+
self._last_mock_backend_status = cast(
688+
"MockFFEAgentlessBackendStatus",
689+
json.loads(self._mock_backend_status_path.read_text(encoding="utf-8")),
690+
)
691+
692+
def _stop_mock_backend(self, *, persist_status: bool = True) -> None:
677693
backend = self._mock_backend
678694
if backend is None:
679695
return
680696

681697
self._mock_backend = None
682698
try:
683-
self._last_mock_backend_status = backend.status()
699+
if persist_status:
700+
self._last_mock_backend_status = backend.status()
701+
self._mock_backend_status_path.parent.mkdir(parents=True, exist_ok=True)
702+
self._mock_backend_status_path.write_text(
703+
json.dumps(self._last_mock_backend_status, indent=2) + "\n",
704+
encoding="utf-8",
705+
)
684706
finally:
685707
backend.close()
686708

utils/build/docker/nodejs/express/app.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -867,20 +867,40 @@ app.post('/ai_guard/evaluate', async (req, res) => {
867867
})
868868

869869
let openFeatureClient = null
870+
let openFeatureClientPromise = null
870871

871-
// Initialize OpenFeature provider if FFE is enabled
872-
if (process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED === 'true') {
873-
const { openfeature } = tracer
874-
OpenFeature.setProvider(openfeature)
875-
openFeatureClient = OpenFeature.getClient()
872+
async function getOpenFeatureClient () {
873+
if (openFeatureClient) {
874+
return openFeatureClient
875+
}
876+
877+
if (process.env.DD_FEATURE_FLAGS_ENABLED === 'false') {
878+
return null
879+
}
880+
881+
if (!openFeatureClientPromise) {
882+
const { openfeature } = tracer
883+
openFeatureClientPromise = OpenFeature.setProviderAndWait(openfeature)
884+
.then(() => {
885+
openFeatureClient = OpenFeature.getClient()
886+
return openFeatureClient
887+
})
888+
.catch(error => {
889+
openFeatureClientPromise = null
890+
throw error
891+
})
892+
}
893+
894+
return openFeatureClientPromise
876895
}
877896

878897
// Single FFE endpoint that evaluates feature flags
879898
app.post('/ffe', async (req, res) => {
880899
try {
881900
const { flag, variationType, defaultValue, targetingKey, targetingKeys, attributes } = req.body
901+
const client = await getOpenFeatureClient()
882902

883-
if (!openFeatureClient) {
903+
if (!client) {
884904
return res.status(500).json({ error: 'FFE provider not initialized' })
885905
}
886906

@@ -892,17 +912,17 @@ app.post('/ffe', async (req, res) => {
892912

893913
switch (variationType) {
894914
case 'BOOLEAN':
895-
value = await openFeatureClient.getBooleanValue(flag, defaultValue, context)
915+
value = await client.getBooleanValue(flag, defaultValue, context)
896916
break
897917
case 'STRING':
898-
value = await openFeatureClient.getStringValue(flag, defaultValue, context)
918+
value = await client.getStringValue(flag, defaultValue, context)
899919
break
900920
case 'INTEGER':
901921
case 'NUMERIC':
902-
value = await openFeatureClient.getNumberValue(flag, defaultValue, context)
922+
value = await client.getNumberValue(flag, defaultValue, context)
903923
break
904924
case 'JSON':
905-
value = await openFeatureClient.getObjectValue(flag, defaultValue, context)
925+
value = await client.getObjectValue(flag, defaultValue, context)
906926
break
907927
default:
908928
return res.status(400).json({ error: `Unknown variation type: ${variationType}` })

utils/build/docker/nodejs/express4-typescript.Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ ENV PGPORT=5433
1010

1111
ENV DD_DATA_STREAMS_ENABLED=true
1212

13+
# Refresh the application code and dependencies baked into the base image.
14+
COPY utils/build/docker/nodejs/express4-typescript/package.json utils/build/docker/nodejs/express4-typescript/bun.lock ./
15+
COPY utils/build/docker/nodejs/express4-typescript/app.ts app.ts
16+
RUN bun install --frozen-lockfile --network-concurrency 8 --linker=hoisted
17+
1318
COPY utils/build/docker/nodejs/install_ddtrace.sh binaries* /binaries/
1419
RUN /binaries/install_ddtrace.sh && rm -rf /root/.bun
1520
RUN bun run build

utils/build/docker/nodejs/express4-typescript/app.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
'use strict'
22

33
import { Request, Response } from "express";
4+
import type { Client } from '@openfeature/server-sdk'
45
import http from 'http';
56

67
const tracer = require('dd-trace').init();
78
const { tags: { MANUAL_KEEP, MANUAL_DROP } } = require('dd-trace/ext');
89

10+
const { OpenFeature } = require('@openfeature/server-sdk')
911
const { promisify } = require('util')
1012
const app = require('express')()
1113
const axios = require('axios')
@@ -555,6 +557,74 @@ app.get('/external_request/redirect', (req: Request, res: Response) => {
555557

556558
require('./rasp')(app)
557559

560+
let openFeatureClient: Client | null = null
561+
let openFeatureClientPromise: Promise<Client> | null = null
562+
563+
async function getOpenFeatureClient (): Promise<Client | null> {
564+
if (openFeatureClient) {
565+
return openFeatureClient
566+
}
567+
568+
if (process.env.DD_FEATURE_FLAGS_ENABLED === 'false') {
569+
return null
570+
}
571+
572+
if (!openFeatureClientPromise) {
573+
openFeatureClientPromise = OpenFeature.setProviderAndWait(tracer.openfeature)
574+
.then(() => {
575+
openFeatureClient = OpenFeature.getClient()
576+
return openFeatureClient
577+
})
578+
.catch((error: unknown) => {
579+
openFeatureClientPromise = null
580+
throw error
581+
})
582+
}
583+
584+
return openFeatureClientPromise
585+
}
586+
587+
app.post('/ffe', async (req: Request, res: Response) => {
588+
try {
589+
const { flag, variationType, defaultValue, targetingKey, targetingKeys, attributes } = req.body
590+
const client = await getOpenFeatureClient()
591+
592+
if (!client) {
593+
return res.status(500).json({ error: 'FFE provider not initialized' })
594+
}
595+
596+
let value
597+
const keys = Array.isArray(targetingKeys) && targetingKeys.length > 0 ? targetingKeys : [targetingKey]
598+
599+
for (const key of keys) {
600+
const context = { targetingKey: key, ...attributes }
601+
602+
switch (variationType) {
603+
case 'BOOLEAN':
604+
value = await client.getBooleanValue(flag, defaultValue, context)
605+
break
606+
case 'STRING':
607+
value = await client.getStringValue(flag, defaultValue, context)
608+
break
609+
case 'INTEGER':
610+
case 'NUMERIC':
611+
value = await client.getNumberValue(flag, defaultValue, context)
612+
break
613+
case 'JSON':
614+
value = await client.getObjectValue(flag, defaultValue, context)
615+
break
616+
default:
617+
return res.status(400).json({ error: `Unknown variation type: ${variationType}` })
618+
}
619+
}
620+
621+
return res.status(200).json({ value, count: keys.length })
622+
} catch (error: any) {
623+
console.error('[FFE] Error:', error)
624+
return res.status(500).json({ error: error.message })
625+
}
626+
})
627+
558628
const startServer = () => {
559629
return new Promise((resolve) => {
560630
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {

utils/build/docker/nodejs/express4-typescript/bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)