Skip to main content

mz_adapter/config/
frontend.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use bytes::Bytes;
16use derivative::Derivative;
17use futures::TryStreamExt;
18use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture};
19use launchdarkly_server_sdk as ld;
20use mz_build_info::BuildInfo;
21use mz_cloud_provider::CloudProvider;
22use mz_cluster_client::ReplicaId;
23use mz_controller_types::ClusterId;
24use mz_ore::metrics::UIntGauge;
25use mz_ore::now::NowFn;
26use mz_sql::catalog::EnvironmentId;
27use serde_json::Value as JsonValue;
28use tokio::time;
29use tracing::warn;
30
31use crate::config::{
32    Metrics, SynchronizedParameters, SystemParameterSyncClientConfig, SystemParameterSyncConfig,
33};
34
35/// A frontend client for pulling [SynchronizedParameters] from LaunchDarkly.
36#[derive(Derivative)]
37#[derivative(Debug)]
38pub struct SystemParameterFrontend {
39    /// An SDK client to mediate interactions with the LaunchDarkly and json config file clients.
40    client: SystemParameterFrontendClient,
41    /// A map from parameter names to LaunchDarkly feature keys
42    /// to use when populating the [SynchronizedParameters]
43    /// instance in [SystemParameterFrontend::pull].
44    key_map: BTreeMap<String, String>,
45    /// The environment ID, used to build scoped (`cluster` / `replica`)
46    /// evaluation contexts.
47    env_id: EnvironmentId,
48    /// Build info, used to build scoped evaluation contexts.
49    build_info: &'static BuildInfo,
50    /// Frontend metrics.
51    metrics: Metrics,
52}
53
54#[derive(Derivative)]
55#[derivative(Debug)]
56pub enum SystemParameterFrontendClient {
57    File {
58        path: PathBuf,
59    },
60    LaunchDarkly {
61        /// An SDK client to mediate interactions with the LaunchDarkly client.
62        #[derivative(Debug = "ignore")]
63        client: ld::Client,
64        /// The context to use when querying LaunchDarkly using the SDK.
65        /// This scopes down queries to a specific key.
66        ctx: ld::Context,
67    },
68}
69
70impl SystemParameterFrontendClient {}
71
72impl SystemParameterFrontend {
73    /// Create a new [SystemParameterFrontend] initialize.
74    ///
75    /// This will create and initialize an [ld::Client] instance. The
76    /// [ld::Client::wait_for_initialization] call will be attempted in a loop with an
77    /// exponential backoff with power `2s` and max duration `60s`.
78    pub async fn from(sync_config: &SystemParameterSyncConfig) -> Result<Self, anyhow::Error> {
79        match &sync_config.backend_config {
80            super::SystemParameterSyncClientConfig::File { path } => Ok(Self {
81                client: SystemParameterFrontendClient::File { path: path.clone() },
82                key_map: sync_config.key_map.clone(),
83                env_id: sync_config.env_id.clone(),
84                build_info: sync_config.build_info,
85                metrics: sync_config.metrics.clone(),
86            }),
87            SystemParameterSyncClientConfig::LaunchDarkly {
88                sdk_key,
89                base_uri,
90                now_fn,
91            } => Ok(Self {
92                client: SystemParameterFrontendClient::LaunchDarkly {
93                    client: ld_client(sdk_key, base_uri.as_deref(), &sync_config.metrics, now_fn)
94                        .await?,
95                    // The environment-wide context carries no cluster/replica
96                    // scope. Scoped evaluation passes a `cluster` or `replica`
97                    // context per pass via [`ld_ctx`].
98                    ctx: ld_ctx(&sync_config.env_id, sync_config.build_info, None, None)?,
99                },
100                env_id: sync_config.env_id.clone(),
101                build_info: sync_config.build_info,
102                metrics: sync_config.metrics.clone(),
103                key_map: sync_config.key_map.clone(),
104            }),
105        }
106    }
107
108    /// Pull the current values for all [SynchronizedParameters] from the
109    /// [SystemParameterFrontend] and return `true` iff at least one parameter
110    /// value was modified.
111    pub fn pull(&self, params: &mut SynchronizedParameters) -> bool {
112        let mut changed = false;
113        for param_name in params.synchronized().into_iter() {
114            let flag_name = self
115                .key_map
116                .get(param_name)
117                .map(|flag_name| flag_name.as_str())
118                .unwrap_or(param_name);
119
120            let flag_str = match self.client {
121                SystemParameterFrontendClient::LaunchDarkly {
122                    ref client,
123                    ref ctx,
124                } => {
125                    let flag_var = client.variation(ctx, flag_name, params.get(param_name));
126                    match flag_var {
127                        ld::FlagValue::Bool(v) => v.to_string(),
128                        ld::FlagValue::Str(v) => v,
129                        ld::FlagValue::Number(v) => v.to_string(),
130                        ld::FlagValue::Json(v) => v.to_string(),
131                    }
132                }
133                SystemParameterFrontendClient::File { ref path } => {
134                    let file_contents = fs::read_to_string(path)
135                        .inspect_err(|e| warn!("Could not open system paraemter sync file {}", e))
136                        .unwrap_or_default();
137                    let values: BTreeMap<String, JsonValue> = serde_json::from_str(&file_contents)
138                        .inspect_err(|e| warn!("Could not open system paraemter sync file {:?}", e))
139                        .unwrap_or_default();
140                    values
141                        .get(flag_name)
142                        .and_then(|o| match o {
143                            serde_json::Value::String(v) => Some(v.to_string()),
144                            serde_json::Value::Number(v) => Some(v.to_string()),
145                            serde_json::Value::Bool(v) => Some(v.to_string()),
146                            serde_json::Value::Object(_) => Some(o.to_string()),
147                            serde_json::Value::Array(_) => Some(o.to_string()),
148                            serde_json::Value::Null => None,
149                        })
150                        .unwrap_or_else(|| params.get(param_name))
151                }
152            };
153
154            let old = params.get(param_name);
155            let change = params.modify(param_name, flag_str.as_str());
156            if change {
157                tracing::debug!(
158                    %param_name, %old, new = %flag_str,
159                    "updating system param",
160                );
161            }
162            self.metrics.params_changed.inc_by(u64::from(change));
163            changed |= change;
164        }
165
166        changed
167    }
168
169    /// Evaluates the replica-local scoped parameters for each given replica and
170    /// returns, per cluster and replica, the parameter values that differ from
171    /// the environment-wide value held in `params`.
172    ///
173    /// Only the LaunchDarkly client performs scoped evaluation. The file
174    /// client returns an empty map (replicas fall back to the environment-wide value).
175    /// The returned map is sparse: replicas (and clusters) with no overriding
176    /// value are omitted.
177    pub fn pull_replica_overrides(
178        &self,
179        params: &SynchronizedParameters,
180        param_names: &[&'static str],
181        replicas: &[ReplicaEvalContext],
182    ) -> BTreeMap<ReplicaId, BTreeMap<String, String>> {
183        let mut out: BTreeMap<ReplicaId, BTreeMap<String, String>> = BTreeMap::new();
184
185        let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
186            // The file client has no notion of scoped evaluation.
187            return out;
188        };
189
190        if param_names.is_empty() {
191            return out;
192        }
193
194        for replica in replicas {
195            let ctx = match ld_ctx(
196                &self.env_id,
197                self.build_info,
198                Some(&replica.cluster),
199                Some(&replica.replica),
200            ) {
201                Ok(ctx) => ctx,
202                Err(e) => {
203                    warn!(
204                        replica_id = %replica.replica.id,
205                        "could not build scoped LD context: {e}"
206                    );
207                    continue;
208                }
209            };
210
211            let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
212            if !overrides.is_empty() {
213                out.insert(replica.replica_id, overrides);
214            }
215        }
216
217        out
218    }
219
220    /// Evaluates the cluster-coherent scoped parameters for each given cluster
221    /// and returns, per cluster, the parameter values that differ from the
222    /// environment-wide value held in `params`. Evaluated replica-free (the
223    /// `cluster` context kind), so the value cannot vary by replica.
224    ///
225    /// Only the LaunchDarkly client performs scoped evaluation. The file
226    /// client returns an empty map. The returned map is sparse.
227    pub fn pull_cluster_overrides(
228        &self,
229        params: &SynchronizedParameters,
230        param_names: &[&'static str],
231        clusters: &[ClusterEvalContext],
232    ) -> BTreeMap<ClusterId, BTreeMap<String, String>> {
233        let mut out: BTreeMap<ClusterId, BTreeMap<String, String>> = BTreeMap::new();
234
235        let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
236            // The file client has no notion of scoped evaluation.
237            return out;
238        };
239
240        if param_names.is_empty() {
241            return out;
242        }
243
244        for cluster in clusters {
245            let ctx = match ld_ctx(&self.env_id, self.build_info, Some(&cluster.cluster), None) {
246                Ok(ctx) => ctx,
247                Err(e) => {
248                    warn!(
249                        cluster_id = %cluster.cluster.id,
250                        "could not build scoped LD context: {e}"
251                    );
252                    continue;
253                }
254            };
255
256            let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
257            if !overrides.is_empty() {
258                out.insert(cluster.cluster_id, overrides);
259            }
260        }
261
262        out
263    }
264
265    /// Evaluates each of `param_names` against `ctx`, returning only the values
266    /// that differ from the environment-wide value held in `params`. Shared by
267    /// the cluster and replica passes, so the returned map is sparse.
268    ///
269    /// We record on the differs-from-env test, not the `variation_detail`
270    /// reason. The inline comment at the recording decision explains why.
271    fn evaluate_scoped_overrides(
272        &self,
273        client: &ld::Client,
274        ctx: &ld::Context,
275        params: &SynchronizedParameters,
276        param_names: &[&'static str],
277    ) -> BTreeMap<String, String> {
278        let mut overrides = BTreeMap::new();
279        for &param_name in param_names {
280            let flag_name = self
281                .key_map
282                .get(param_name)
283                .map(|flag_name| flag_name.as_str())
284                .unwrap_or(param_name);
285
286            let base = params.get(param_name);
287            // Evaluate with `base` as the default, so a silent LD (flag absent,
288            // off, error, failed prerequisite) resolves back to the env-wide
289            // value and is dropped by the difference test below.
290            let flag_var = client.variation(ctx, flag_name, base.clone());
291            let value = match flag_var {
292                ld::FlagValue::Bool(v) => v.to_string(),
293                ld::FlagValue::Str(v) => v,
294                ld::FlagValue::Number(v) => v.to_string(),
295                ld::FlagValue::Json(v) => v.to_string(),
296            };
297
298            // Record iff the scoped evaluation *differs* from the env-wide value.
299            // The `variation_detail` reason is the wrong signal: it cannot say
300            // which context kind's clause matched (an env-level rule and a
301            // cluster-specific rule both report `RuleMatch`), and `Fallthrough`
302            // serves the env-wide value to every object. Comparing against the
303            // env-wide baseline is the only signal that means "this scope context
304            // changed the answer", which is what must beat a manual `FEATURES`
305            // pin and what keeps the durable collections sparse. See the scoped
306            // feature flags design, §Resolution.
307            //
308            // Compare in the parameter's canonical encoding. `base` is the
309            // var-formatted env-wide value (a `bool` is `"on"`/`"off"`), whereas
310            // the raw LaunchDarkly value spells a boolean `"true"`/`"false"`, so a
311            // direct string compare would treat every boolean flag as differing,
312            // even on `Fallthrough`. We still *store* the raw `value` (downstream
313            // consumers parse `"true"`/`"false"`). Only the decision is canonical.
314            let differs = match params.canonicalize(param_name, &value) {
315                Some(canonical) => canonical != base,
316                // LaunchDarkly served a value that does not parse for this
317                // parameter's type (e.g. a malformed boolean like `"maybe"`).
318                // Never record it: storing an unparseable value would poison
319                // resolution. The optimizer's `bool` decode, for one, panics on
320                // every plan for a cluster-coherent override it cannot parse.
321                // Treat it as "no scoped opinion" and fall back to the env-wide
322                // value.
323                None => false,
324            };
325            if differs {
326                overrides.insert(param_name.to_string(), value);
327            }
328        }
329        overrides
330    }
331}
332
333/// The identity of a single live replica, used to evaluate replica-local scoped
334/// parameters in [`SystemParameterFrontend::pull_replica_overrides`].
335#[derive(Clone, Debug)]
336pub struct ReplicaEvalContext {
337    /// The owning cluster's id.
338    pub cluster_id: ClusterId,
339    /// The replica's id.
340    pub replica_id: ReplicaId,
341    /// The owning cluster's scope context (for the replica-free, cluster pass).
342    pub cluster: ClusterScopeContext,
343    /// The replica's scope context.
344    pub replica: ReplicaScopeContext,
345}
346
347/// The identity of a single live cluster, used to evaluate cluster-coherent
348/// scoped parameters in [`SystemParameterFrontend::pull_cluster_overrides`].
349#[derive(Clone, Debug)]
350pub struct ClusterEvalContext {
351    /// The cluster's id.
352    pub cluster_id: ClusterId,
353    /// The cluster's scope context (replica-free).
354    pub cluster: ClusterScopeContext,
355}
356
357/// An [`HttpTransport`] wrapper that records timestamps on successful HTTP
358/// responses. Used to populate Prometheus metrics that track LaunchDarkly
359/// connectivity health.
360///
361/// Two instances are created — one for the event processor (CSE metric, tracks
362/// outbound event sends) and one for the streaming data source (SSE metric,
363/// tracks inbound SSE events).
364#[derive(Clone)]
365struct MetricsTransport<T> {
366    inner: T,
367    last_success_gauge: UIntGauge,
368    now_fn: NowFn,
369}
370
371impl<T: HttpTransport> HttpTransport for MetricsTransport<T> {
372    fn request(&self, request: http::Request<Option<Bytes>>) -> ResponseFuture {
373        let inner_fut = self.inner.request(request);
374        let gauge = self.last_success_gauge.clone();
375        let now_fn = self.now_fn.clone();
376        Box::pin(async move {
377            let resp = inner_fut.await?;
378            if resp.status().is_success() {
379                gauge.set(now_fn() / 1000);
380                let (parts, body) = resp.into_parts();
381                let wrapped: ByteStream = Box::pin(body.inspect_ok(move |_| {
382                    gauge.set(now_fn() / 1000);
383                }));
384                Ok(http::Response::from_parts(parts, wrapped))
385            } else {
386                Ok(resp)
387            }
388        })
389    }
390}
391
392fn ld_config(
393    api_key: &str,
394    base_uri: Option<&str>,
395    metrics: &Metrics,
396    now_fn: &NowFn,
397) -> ld::Config {
398    // How long a body read on the streaming connection may stay idle before
399    // the transport surfaces a timeout error to the data source. This is the
400    // error class of incident-984 (a silently-dead connection). Overridable
401    // via a hidden env var so tests can trigger the timeout path in seconds
402    // instead of minutes (see test/launchdarkly-reconnect).
403    //
404    // The default must stay above LaunchDarkly's streaming heartbeat interval
405    // (roughly 3 minutes per LD's documentation), or a healthy idle stream
406    // would trip the timeout and reconnect spuriously. Benign now that
407    // reconnects work, but wasteful. The same constant lives in
408    // `mz-dyncfg-launchdarkly`.
409    let read_timeout = match std::env::var("MZ_LAUNCHDARKLY_READ_TIMEOUT") {
410        Ok(v) => humantime::parse_duration(&v).unwrap_or_else(|e| {
411            // Don't silently fall back: a typo here (e.g. `5sec`) would
412            // otherwise present as an unexplained timeout far downstream.
413            tracing::error!(
414                "ignoring unparseable MZ_LAUNCHDARKLY_READ_TIMEOUT {v:?}: {e}; \
415                 falling back to default"
416            );
417            Duration::from_secs(300)
418        }),
419        Err(_) => Duration::from_secs(300),
420    };
421
422    // NOTE: `HyperTransport` auto-detects the `HTTP_PROXY`/`HTTPS_PROXY`/
423    // `NO_PROXY` env vars and routes through a configured proxy. No exposure
424    // today (our cloud pods set no proxy vars, self-managed never builds an LD
425    // client), but worth knowing if proxy vars ever appear on a pod.
426    let transport = launchdarkly_sdk_transport::HyperTransport::builder()
427        .connect_timeout(Duration::from_secs(10))
428        .read_timeout(read_timeout)
429        .build_https()
430        .expect("failed to create HTTPS transport");
431
432    let cse_transport = MetricsTransport {
433        inner: transport.clone(),
434        last_success_gauge: metrics.last_cse_time_seconds.clone(),
435        now_fn: now_fn.clone(),
436    };
437    let data_source_transport = MetricsTransport {
438        inner: transport,
439        last_success_gauge: metrics.last_sse_time_seconds.clone(),
440        now_fn: now_fn.clone(),
441    };
442
443    let mut event_processor = ld::EventProcessorBuilder::new();
444    event_processor.transport(cse_transport);
445
446    let mut data_source = ld::StreamingDataSourceBuilder::new();
447    data_source.transport(data_source_transport);
448
449    let mut config = ld::ConfigBuilder::new(api_key)
450        .event_processor(&event_processor)
451        .data_source(&data_source);
452    if let Some(base_uri) = base_uri {
453        let mut endpoints = ld::ServiceEndpointsBuilder::new();
454        endpoints.relay_proxy(base_uri);
455        config = config.service_endpoints(&endpoints);
456    }
457    config.build().expect("valid config")
458}
459
460async fn ld_client(
461    api_key: &str,
462    base_uri: Option<&str>,
463    metrics: &Metrics,
464    now_fn: &NowFn,
465) -> Result<ld::Client, anyhow::Error> {
466    let ld_client = ld::Client::build(ld_config(api_key, base_uri, metrics, now_fn))?;
467    tracing::info!("waiting for SystemParameterFrontend to initialize");
468    ld_client.start_with_default_executor();
469
470    let max_backoff = Duration::from_secs(60);
471    let mut backoff = Duration::from_secs(5);
472    let timeout = Duration::from_secs(10);
473
474    // TODO(materialize#32030): fix retry logic
475    loop {
476        match ld_client.wait_for_initialization(timeout).await {
477            Some(true) => break,
478            Some(false) => tracing::warn!("SystemParameterFrontend failed to initialize"),
479            None => tracing::warn!("SystemParameterFrontend initialization timed out"),
480        }
481
482        time::sleep(backoff).await;
483        backoff = (backoff * 2).min(max_backoff);
484    }
485
486    tracing::info!("successfully initialized SystemParameterFrontend");
487
488    Ok(ld_client)
489}
490
491/// Identity of a cluster, used to build a `cluster` context kind for
492/// cluster-coherent scoped feature flags.
493///
494/// Exposes both `id` and `name`: an LD rule that targets `cluster_id` is an
495/// incarnation pin that dies on drop/recreate (ids are never reused), while a
496/// rule targeting `cluster_name` / `is_builtin` is a durable role predicate
497/// that re-applies to any matching cluster. See the scoped feature flags
498/// design.
499#[derive(Clone, Debug)]
500pub struct ClusterScopeContext {
501    /// The cluster's catalog id, e.g. `s2` or `u1`.
502    pub id: String,
503    /// The cluster's name, e.g. `mz_catalog_server`.
504    pub name: String,
505    /// Whether the cluster is a builtin (system) cluster.
506    pub is_builtin: bool,
507}
508
509/// Identity of a replica, used to build a `replica` context kind for
510/// replica-local scoped feature flags.
511///
512/// Carries the owning cluster's identity as attributes so that replica-local
513/// flags can be cluster-targeted without a second evaluation, and the replica
514/// size and size *family* so flags can be keyed by size family (e.g. legacy
515/// sizes keep `lgalloc`). See the scoped feature flags design.
516#[derive(Clone, Debug)]
517pub struct ReplicaScopeContext {
518    /// The replica's catalog id.
519    pub id: String,
520    /// The replica's name.
521    pub name: String,
522    /// Whether the replica belongs to a builtin (system) cluster.
523    pub is_builtin: bool,
524    /// The replica's full size name, e.g. `D.1-xsmall` or a legacy t-shirt size
525    /// like `xsmall`. This is the fine-grained targeting axis. The coarse axis
526    /// is [`Self::size_family`]. The two are distinct: `D.1-xsmall` is a size,
527    /// `D` is its family.
528    pub size: String,
529    /// The replica's size family, e.g. `D` or `legacy`. The coarse targeting
530    /// axis, derived from the size map rather than the size name (see
531    /// [`Self::size`]).
532    pub size_family: String,
533    /// The owning cluster's catalog id.
534    pub cluster_id: String,
535    /// The owning cluster's name.
536    pub cluster_name: String,
537}
538
539/// Builds a single `cluster` context kind from a [`ClusterScopeContext`].
540///
541/// Deliberately replica-free: cluster-coherent flags must resolve identically
542/// across a cluster's replicas, so no replica/size attributes appear here.
543fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
544    ld::ContextBuilder::new(cluster.id.as_str())
545        .anonymous(true) // keep the LD dashboard Contexts list clean
546        .kind("cluster")
547        .set_string("cluster_id", cluster.id.clone())
548        .set_string("cluster_name", cluster.name.clone())
549        .set_string("is_builtin", cluster.is_builtin.to_string())
550        .build()
551        .map_err(|e| anyhow::anyhow!(e))
552}
553
554/// Builds a single `replica` context kind from a [`ReplicaScopeContext`].
555///
556/// Includes the owning cluster's identity so a rule can combine both axes,
557/// e.g. "size family `D` *and* cluster `foo`".
558fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
559    ld::ContextBuilder::new(replica.id.as_str())
560        .anonymous(true) // keep the LD dashboard Contexts list clean
561        .kind("replica")
562        .set_string("replica_id", replica.id.clone())
563        .set_string("replica_name", replica.name.clone())
564        .set_string("is_builtin", replica.is_builtin.to_string())
565        .set_string("replica_size", replica.size.clone())
566        .set_string("replica_size_family", replica.size_family.clone())
567        .set_string("cluster_id", replica.cluster_id.clone())
568        .set_string("cluster_name", replica.cluster_name.clone())
569        .build()
570        .map_err(|e| anyhow::anyhow!(e))
571}
572
573/// Builds a multi-context for evaluating scoped feature flags.
574///
575/// Composes the base contexts (`environment` + `organization` + `build`) with:
576/// - a `cluster` context for cluster-coherent (replica-free) resolution, and/or
577/// - a `replica` context for replica-local resolution.
578///
579/// The environment-wide pass passes `None` for both. This is the single entry
580/// point the sync loop uses to evaluate each scoped pass.
581fn ld_ctx(
582    env_id: &EnvironmentId,
583    build_info: &'static BuildInfo,
584    cluster: Option<&ClusterScopeContext>,
585    replica: Option<&ReplicaScopeContext>,
586) -> Result<ld::Context, anyhow::Error> {
587    // Register multiple contexts for this client.
588    //
589    // Unfortunately, it seems that the order in which conflicting targeting
590    // rules are applied depends on the definition order of feature flag
591    // variations rather than on the order in which context are registered with
592    // the multi-context builder.
593    let mut ctx_builder = ld::MultiContextBuilder::new();
594
595    if env_id.cloud_provider() != &CloudProvider::Local {
596        ctx_builder.add_context(
597            ld::ContextBuilder::new(env_id.to_string())
598                .kind("environment")
599                .set_string("cloud_provider", env_id.cloud_provider().to_string())
600                .set_string("cloud_provider_region", env_id.cloud_provider_region())
601                .set_string("organization_id", env_id.organization_id().to_string())
602                .set_string("ordinal", env_id.ordinal().to_string())
603                .build()
604                .map_err(|e| anyhow::anyhow!(e))?,
605        );
606        ctx_builder.add_context(
607            ld::ContextBuilder::new(env_id.organization_id().to_string())
608                .kind("organization")
609                .build()
610                .map_err(|e| anyhow::anyhow!(e))?,
611        );
612    } else {
613        // If cloud_provider is 'local', use anonymous `environment` and
614        // `organization` contexts with fixed keys, as otherwise we will create
615        // a lot of additional contexts (which are the billable entity for
616        // LaunchDarkly).
617        ctx_builder.add_context(
618            ld::ContextBuilder::new("anonymous-dev@materialize.com")
619                .anonymous(true) // exclude this user from the dashboard
620                .kind("environment")
621                .set_string("cloud_provider", env_id.cloud_provider().to_string())
622                .set_string("cloud_provider_region", env_id.cloud_provider_region())
623                .set_string("organization_id", uuid::Uuid::nil().to_string())
624                .set_string("ordinal", env_id.ordinal().to_string())
625                .build()
626                .map_err(|e| anyhow::anyhow!(e))?,
627        );
628        ctx_builder.add_context(
629            ld::ContextBuilder::new(uuid::Uuid::nil().to_string())
630                .anonymous(true) // exclude this user from the dashboard
631                .kind("organization")
632                .build()
633                .map_err(|e| anyhow::anyhow!(e))?,
634        );
635    };
636
637    ctx_builder.add_context(
638        ld::ContextBuilder::new(build_info.sha)
639            .kind("build")
640            .set_string("semver_version", build_info.semver_version().to_string())
641            .build()
642            .map_err(|e| anyhow::anyhow!(e))?,
643    );
644
645    // Cluster-coherent resolution evaluates with a `cluster` context (no
646    // replica attributes). Replica-local resolution additionally carries a
647    // `replica` context. The environment-wide pass carries neither.
648    if let Some(cluster) = cluster {
649        ctx_builder.add_context(cluster_context(cluster)?);
650    }
651    if let Some(replica) = replica {
652        ctx_builder.add_context(replica_context(replica)?);
653    }
654
655    ctx_builder.build().map_err(|e| anyhow::anyhow!(e))
656}
657
658#[cfg(test)]
659mod tests {
660    use std::sync::Arc;
661    use std::sync::atomic::{AtomicU64, Ordering};
662
663    use futures::StreamExt;
664    use launchdarkly_sdk_transport::{ByteStream, TransportError};
665    use mz_build_info::DUMMY_BUILD_INFO;
666    use mz_ore::metrics::MetricsRegistry;
667
668    use super::*;
669
670    fn env_id() -> EnvironmentId {
671        EnvironmentId::for_tests()
672    }
673
674    #[mz_ore::test]
675    fn builds_cluster_scoped_context() {
676        // Cluster-coherent resolution evaluates with a replica-free `cluster`
677        // context.
678        let cluster = ClusterScopeContext {
679            id: "s2".into(),
680            name: "mz_catalog_server".into(),
681            is_builtin: true,
682        };
683        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), None)
684            .expect("cluster-scoped context builds");
685    }
686
687    #[mz_ore::test]
688    fn builds_replica_scoped_context() {
689        // Replica-local resolution carries both a `cluster` and a `replica`
690        // context so a rule can combine size family and cluster.
691        let cluster = ClusterScopeContext {
692            id: "u1".into(),
693            name: "quickstart".into(),
694            is_builtin: false,
695        };
696        let replica = ReplicaScopeContext {
697            id: "u1-replica-1".into(),
698            name: "r1".into(),
699            is_builtin: false,
700            size: "D.1-xsmall".into(),
701            size_family: "D".into(),
702            cluster_id: "u1".into(),
703            cluster_name: "quickstart".into(),
704        };
705        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), Some(&replica))
706            .expect("replica-scoped context builds");
707    }
708
709    #[mz_ore::test]
710    fn environment_wide_context_is_unscoped() {
711        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, None, None).expect("environment-wide context builds");
712    }
713
714    /// A fake transport that simulates a long-lived SSE streaming connection:
715    /// returns 200 OK immediately, then delivers multiple SSE events as body
716    /// chunks (exactly how LaunchDarkly's streaming data source works).
717    #[derive(Clone)]
718    struct FakeSseTransport;
719
720    impl HttpTransport for FakeSseTransport {
721        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
722            let body: ByteStream = Box::pin(futures::stream::iter(vec![
723                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
724                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag1\"}\n\n")),
725                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag2\"}\n\n")),
726            ]));
727            Box::pin(async move {
728                http::Response::builder()
729                    .status(200)
730                    .body(body)
731                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
732            })
733        }
734    }
735
736    /// A fake transport that returns an error, simulating a failed connection.
737    #[derive(Clone)]
738    struct FailingTransport;
739
740    impl HttpTransport for FailingTransport {
741        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
742            Box::pin(async move {
743                Err(TransportError::new(std::io::Error::new(
744                    std::io::ErrorKind::ConnectionRefused,
745                    "connection refused",
746                )))
747            })
748        }
749    }
750
751    /// A fake transport that returns 200 OK, delivers one event, then errors
752    /// mid-stream with a timeout: the non-Eof stream error a dropped long-lived
753    /// SSE connection surfaces.
754    #[derive(Clone)]
755    struct MidStreamFailureTransport;
756
757    impl HttpTransport for MidStreamFailureTransport {
758        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
759            let body: ByteStream = Box::pin(futures::stream::iter(vec![
760                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
761                Err(TransportError::new(std::io::Error::new(
762                    std::io::ErrorKind::TimedOut,
763                    "body timed out",
764                ))),
765            ]));
766            Box::pin(async move {
767                http::Response::builder()
768                    .status(200)
769                    .body(body)
770                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
771            })
772        }
773    }
774
775    fn test_gauge(registry: &MetricsRegistry, name: &str) -> UIntGauge {
776        registry.register(mz_ore::metric!(
777            name: name,
778            help: "test gauge",
779        ))
780    }
781
782    /// Verifies that MetricsTransport updates the gauge on each body chunk,
783    /// not just on the initial HTTP 200 response head. This matters for
784    /// long-lived streaming connections where SSE events arrive as body chunks.
785    #[mz_ore::test(tokio::test)]
786    async fn test_metric_updated_on_body_chunks() -> Result<(), anyhow::Error> {
787        let time = Arc::new(AtomicU64::new(1_000_000));
788        let time_clone = Arc::clone(&time);
789        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
790
791        let registry = MetricsRegistry::new();
792        let gauge = test_gauge(&registry, "test_sse_gauge");
793
794        let transport = MetricsTransport {
795            inner: FakeSseTransport,
796            last_success_gauge: gauge.clone(),
797            now_fn,
798        };
799
800        assert_eq!(gauge.get(), 0);
801
802        let request = http::Request::builder()
803            .uri("https://stream.launchdarkly.com/all")
804            .body(None)?;
805        let response = transport.request(request).await?;
806
807        assert_eq!(gauge.get(), 1000);
808
809        time.store(2_800_000, Ordering::SeqCst);
810
811        let mut body = response.into_body();
812        let mut event_count = 0;
813        while let Some(Ok(_chunk)) = body.next().await {
814            event_count += 1;
815        }
816        assert_eq!(event_count, 3);
817
818        assert_eq!(gauge.get(), 2800);
819        Ok(())
820    }
821
822    #[mz_ore::test(tokio::test)]
823    async fn test_cse_metric_updates_correctly_per_request() -> Result<(), anyhow::Error> {
824        let time = Arc::new(AtomicU64::new(1_000_000));
825        let time_clone = Arc::clone(&time);
826        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
827
828        let registry = MetricsRegistry::new();
829        let gauge = test_gauge(&registry, "test_cse_gauge");
830
831        let transport = MetricsTransport {
832            inner: FakeSseTransport,
833            last_success_gauge: gauge.clone(),
834            now_fn,
835        };
836
837        let req = || -> Result<http::Request<Option<Bytes>>, http::Error> {
838            http::Request::builder()
839                .uri("https://events.launchdarkly.com/bulk")
840                .body(None)
841        };
842
843        let _ = transport.request(req()?).await?;
844        assert_eq!(gauge.get(), 1000);
845
846        time.store(2_000_000, Ordering::SeqCst);
847        let _ = transport.request(req()?).await?;
848        assert_eq!(gauge.get(), 2000);
849
850        time.store(3_000_000, Ordering::SeqCst);
851        let _ = transport.request(req()?).await?;
852        assert_eq!(gauge.get(), 3000);
853        Ok(())
854    }
855
856    #[mz_ore::test(tokio::test)]
857    async fn test_metric_not_updated_on_failed_request() -> Result<(), anyhow::Error> {
858        let now_fn = NowFn::from(|| 5_000_000u64);
859
860        let registry = MetricsRegistry::new();
861        let gauge = test_gauge(&registry, "test_fail_gauge");
862
863        let transport = MetricsTransport {
864            inner: FailingTransport,
865            last_success_gauge: gauge.clone(),
866            now_fn,
867        };
868
869        let request = http::Request::builder()
870            .uri("https://stream.launchdarkly.com/all")
871            .body(None)?;
872        let result = transport.request(request).await;
873        assert!(result.is_err());
874        assert_eq!(gauge.get(), 0, "gauge must not update on transport error");
875        Ok(())
876    }
877
878    /// Verifies that when an SSE connection returns 200 OK and then dies
879    /// mid-stream, `last_sse_time_seconds` advances only for the events that
880    /// arrived and then freezes — the frozen timestamp is what lets the
881    /// staleness alert detect a stuck data source.
882    #[mz_ore::test(tokio::test)]
883    async fn test_metric_frozen_on_midstream_error() -> Result<(), anyhow::Error> {
884        let time = Arc::new(AtomicU64::new(1_000_000));
885        let time_clone = Arc::clone(&time);
886        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
887
888        let registry = MetricsRegistry::new();
889        let gauge = test_gauge(&registry, "test_midstream_gauge");
890
891        let transport = MetricsTransport {
892            inner: MidStreamFailureTransport,
893            last_success_gauge: gauge.clone(),
894            now_fn,
895        };
896
897        // The 200 OK response head updates the gauge.
898        let request = http::Request::builder()
899            .uri("https://stream.launchdarkly.com/all")
900            .body(None)?;
901        let response = transport.request(request).await?;
902        assert_eq!(gauge.get(), 1000);
903
904        // The first event arrives and advances the gauge.
905        time.store(2_000_000, Ordering::SeqCst);
906        let mut body = response.into_body();
907        assert!(matches!(body.next().await, Some(Ok(_))));
908        assert_eq!(gauge.get(), 2000);
909
910        // The stream then errors mid-flight. Time has moved forward, but the
911        // gauge must stay frozen at the last successful event.
912        time.store(9_000_000, Ordering::SeqCst);
913        assert!(matches!(body.next().await, Some(Err(_))));
914        assert_eq!(
915            gauge.get(),
916            2000,
917            "gauge must freeze on mid-stream error so the staleness alert can fire"
918        );
919        Ok(())
920    }
921}