Skip to main content

mz_environmentd/
telemetry.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
10//! Telemetry collection.
11//!
12//! This report loop collects two types of telemetry data on a regular interval:
13//!
14//!   * Statistics, which represent aggregated activity since the last reporting
15//!     interval. An example of a statistic is "number of SUBSCRIBE queries
16//!     executed in the last reporting interval."
17//!
18//!   * Traits, which represent the properties of the environment at the time of
19//!     reporting. An example of a trait is "number of currently active
20//!     SUBSCRIBE queries."
21//!
22//! The reporting loop makes two Segment API calls each interval:
23//!
24//!   * A `group` API call [0] to report traits. The traits are scoped to the
25//!     environment's cloud provider and region, as in:
26//!
27//!     ```json
28//!     {
29//!         "aws": {
30//!             "us-east-1": {
31//!                 "active_subscribes": 2,
32//!                 ...
33//!             }
34//!         }
35//!     }
36//!     ```
37//!
38//!     Downstream tools often flatten these traits into, e.g.,
39//!     `aws_us_east_1_active_subscribes`.
40//!
41//!  * A `track` API call [1] for the "Environment Rolled Up" event, containing
42//!    both statistics and traits as the event properties, as in:
43//!
44//!    ```json
45//!    {
46//!        "cloud_provider": "aws",
47//!        "cloud_provider_region": "us-east-1",
48//!        "active_subscribes": 1,
49//!        "subscribes": 23,
50//!        ...
51//!    }
52//!    ```
53//!
54//!    This event is only emitted after the *first* reporting interval has
55//!    completed, since at boot all statistics will be zero.
56//!
57//! The reason for including traits in both the `group` and `track` API calls is
58//! because downstream tools want easy access to both of these questions:
59//!
60//!   1. What is the latest state of the environment?
61//!   2. What was the state of this environment in this time window?
62//!
63//! Answering question 2 requires that we periodically report statistics and
64//! traits in a `track` call. Strictly speaking, the `track` event could be used
65//! to answer question 1 too (look for the latest "Environment Rolled Up"
66//! event), but in practice it is often far more convenient to have the latest
67//! state available as a property of the environment.
68//!
69//! [0]: https://segment.com/docs/connections/spec/group/
70//! [1]: https://segment.com/docs/connections/spec/track/
71
72// To test this module, you'll need to run environmentd with
73// the --segment-api-key=<REDACTED> flag. Use the API key from your personal
74// Materialize Cloud stack.
75//
76// You can then use the Segment debugger to watch the events emitted by your
77// environment in real time:
78// https://app.segment.com/materializeinc/sources/cloud_dev/debugger.
79
80use anyhow::bail;
81use chrono::Utc;
82use futures::StreamExt;
83use mz_adapter::PeekResponseUnary;
84use mz_adapter::telemetry::{EventDetails, SegmentClientExt};
85use mz_build_info::BuildInfo;
86use mz_license_keys::ValidatedLicenseKey;
87use mz_ore::collections::CollectionExt;
88use mz_ore::retry::Retry;
89use mz_ore::{soft_panic_or_log, task};
90use mz_repr::adt::jsonb::Jsonb;
91use mz_sql::catalog::EnvironmentId;
92use serde_json::json;
93use tokio::time::{self, Duration};
94
95use crate::BUILD_INFO;
96
97/// Telemetry configuration.
98#[derive(Clone)]
99pub struct Config {
100    /// The Segment client to report telemetry events to.
101    pub segment_client: mz_segment::Client,
102    /// A client to the adapter to introspect.
103    pub adapter_client: mz_adapter::Client,
104    /// The ID of the environment for which to report data.
105    pub environment_id: EnvironmentId,
106    /// The validated license key for this environment. Reported so downstream
107    /// analytics can tell which entitlements are active and whether the key has
108    /// expired.
109    pub license_key: ValidatedLicenseKey,
110    /// The version of the helm chart that deployed this environment, if
111    /// self-managed. Folded into the reported `mz_version` trait.
112    pub helm_chart_version: Option<String>,
113    /// How frequently to send a summary to Segment.
114    pub report_interval: Duration,
115}
116
117/// Starts reporting telemetry events to Segment.
118pub fn start_reporting(config: Config) {
119    task::spawn(|| "telemetry", report_loop(config));
120}
121
122async fn report_loop(
123    Config {
124        segment_client,
125        adapter_client,
126        environment_id,
127        license_key,
128        helm_chart_version,
129        report_interval,
130    }: Config,
131) {
132    struct Stats {
133        deletes: u64,
134        inserts: u64,
135        selects: u64,
136        subscribes: u64,
137        updates: u64,
138    }
139
140    let mut last_stats: Option<Stats> = None;
141
142    let mut interval = time::interval(report_interval);
143    loop {
144        interval.tick().await;
145
146        let traits = Retry::default()
147            .initial_backoff(Duration::from_secs(1))
148            .max_tries(5)
149            .retry_async(|_state| async {
150                let active_subscribes = adapter_client
151                    .metrics()
152                    .active_subscribes
153                    .with_label_values(&["user"])
154                    .get();
155                let mut rows_stream = adapter_client.support_execute_one(&format!("
156                    SELECT jsonb_build_object(
157                        'active_aws_privatelink_connections', (SELECT count(*) FROM mz_connections WHERE id LIKE 'u%' AND type = 'aws-privatelink')::int4,
158                        'active_clusters', (SELECT count(*) FROM mz_clusters WHERE id LIKE 'u%')::int4,
159                        'active_cluster_replicas', (
160                            SELECT jsonb_object_agg(base.size, coalesce(count, 0))
161                            FROM mz_catalog.mz_cluster_replica_sizes base
162                            LEFT JOIN (
163                                SELECT r.size, count(*)::int4
164                                FROM mz_cluster_replicas r
165                                JOIN mz_clusters c ON c.id = r.cluster_id
166                                WHERE c.id LIKE 'u%'
167                                GROUP BY r.size
168                            ) extant ON base.size = extant.size
169                        ),
170                        'active_confluent_schema_registry_connections', (SELECT count(*) FROM mz_connections WHERE id LIKE 'u%' AND type = 'confluent-schema-registry')::int4,
171                        'active_materialized_views', (SELECT count(*) FROM mz_materialized_views WHERE id LIKE 'u%')::int4,
172                        'active_sources', (SELECT count(*) FROM mz_sources WHERE id LIKE 'u%' AND type <> 'subsource')::int4,
173                        'active_kafka_connections', (SELECT count(*) FROM mz_connections WHERE id LIKE 'u%' AND type = 'kafka')::int4,
174                        'active_kafka_sources', (SELECT count(*) FROM mz_sources WHERE id LIKE 'u%' AND type = 'kafka')::int4,
175                        'active_load_generator_sources', (SELECT count(*) FROM mz_sources WHERE id LIKE 'u%' AND type = 'load-generator')::int4,
176                        'active_postgres_connections', (SELECT count(*) FROM mz_connections WHERE id LIKE 'u%' AND type = 'postgres')::int4,
177                        'active_postgres_sources', (SELECT count(*) FROM mz_sources WHERE id LIKE 'u%' AND type = 'postgres')::int4,
178                        'active_sinks', (SELECT count(*) FROM mz_sinks WHERE id LIKE 'u%')::int4,
179                        'active_ssh_tunnel_connections', (SELECT count(*) FROM mz_connections WHERE id LIKE 'u%' AND type = 'ssh-tunnel')::int4,
180                        'active_kafka_sinks', (SELECT count(*) FROM mz_sinks WHERE id LIKE 'u%' AND type = 'kafka')::int4,
181                        'active_tables', (SELECT count(*) FROM mz_tables WHERE id LIKE 'u%')::int4,
182                        'active_views', (SELECT count(*) FROM mz_views WHERE id LIKE 'u%')::int4,
183                        'active_subscribes', {active_subscribes}
184                    )",
185                )).await?;
186
187                let mut row_iters = Vec::new();
188
189                while let Some(rows) = rows_stream.next().await {
190                    match rows {
191                        PeekResponseUnary::Rows(rows) => row_iters.push(rows),
192                        PeekResponseUnary::Canceled => bail!("query canceled"),
193                        PeekResponseUnary::Error(e) => bail!(e),
194                        PeekResponseUnary::DependencyDropped(dep) => {
195                            bail!("{}", dep.query_terminated_error())
196                        }
197                    }
198                }
199
200                let mut rows = Vec::new();
201                for mut row_iter in row_iters {
202                    while let Some(row) = row_iter.next() {
203                        rows.push(row.to_owned());
204                    }
205                }
206
207                assert_eq!(1, rows.len(), "expected one row but got: {:?}", rows);
208                let row = rows.into_first();
209
210                let jsonb = Jsonb::from_row(row);
211                Ok::<_, anyhow::Error>(jsonb.as_ref().to_serde_json())
212            })
213            .await;
214
215        let mut traits = match traits {
216            Ok(traits) => traits,
217            Err(e) => {
218                soft_panic_or_log!("unable to collect telemetry traits: {e}");
219                continue;
220            }
221        };
222
223        if let Some(traits) = traits.as_object_mut() {
224            build_segment_traits(
225                traits,
226                &license_key,
227                &BUILD_INFO,
228                helm_chart_version.clone(),
229            );
230        }
231
232        tracing::info!(?traits, "telemetry traits");
233
234        segment_client.group(
235            // We use the organization ID as the user ID for events
236            // that are not associated with a particular user.
237            environment_id.organization_id(),
238            environment_id.organization_id(),
239            json!({
240                environment_id.cloud_provider().to_string(): {
241                    environment_id.cloud_provider_region(): traits,
242                }
243            }),
244        );
245
246        let query_total = &adapter_client.metrics().query_total;
247        let current_stats = Stats {
248            deletes: query_total.with_label_values(&["user", "delete"]).get(),
249            inserts: query_total.with_label_values(&["user", "insert"]).get(),
250            updates: query_total.with_label_values(&["user", "update"]).get(),
251            selects: query_total.with_label_values(&["user", "select"]).get(),
252            subscribes: query_total.with_label_values(&["user", "subscribe"]).get(),
253        };
254        if let Some(last_stats) = &last_stats {
255            let mut properties = json!({
256                "deletes": current_stats.deletes - last_stats.deletes,
257                "inserts": current_stats.inserts - last_stats.inserts,
258                "updates": current_stats.updates - last_stats.updates,
259                "selects": current_stats.selects - last_stats.selects,
260                "subscribes": current_stats.subscribes - last_stats.subscribes,
261            });
262            properties
263                .as_object_mut()
264                .unwrap()
265                .extend(traits.as_object().unwrap().clone());
266            segment_client.environment_track(
267                &environment_id,
268                "Environment Rolled Up",
269                properties,
270                EventDetails::default(),
271            );
272        }
273        last_stats = Some(current_stats);
274    }
275}
276
277/// Merges the build version and the license key's identity and state into the
278/// collected traits.
279///
280/// The license key is the authoritative source for the organization and
281/// environment IDs. `EnvironmentId::organization_id` only matches the real
282/// organization in cloud SaaS, so we report the license key's `sub`
283/// (organization) and `aud` (environment) instead.
284///
285/// Expiry is recomputed against the wall clock on every call rather than
286/// reusing the flag computed once at startup, so a key that lapses while
287/// environmentd keeps running is reported as expired. The `expiration == 0`
288/// guard exempts the sentinel disabled/emulator key, whose zero expiration is
289/// not a real timestamp.
290fn build_segment_traits(
291    traits: &mut serde_json::Map<String, serde_json::Value>,
292    license_key: &ValidatedLicenseKey,
293    build_info: &BuildInfo,
294    helm_chart_version: Option<String>,
295) {
296    let now_secs = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
297    let license_expired =
298        license_key.expired || (license_key.expiration != 0 && now_secs >= license_key.expiration);
299    traits.insert("organization_id".into(), json!(license_key.organization));
300    traits.insert("environment_id".into(), json!(license_key.environment_id));
301    traits.insert("license_key_id".into(), json!(license_key.id));
302    traits.insert(
303        "license_expiration_timestamp".into(),
304        json!(license_key.expiration),
305    );
306    traits.insert("license_expired".into(), json!(license_expired));
307    traits.insert(
308        "license_expiration_behavior".into(),
309        json!(license_key.expiration_behavior),
310    );
311    traits.insert(
312        "mz_version".into(),
313        json!(build_info.human_version(helm_chart_version)),
314    );
315}
316
317#[cfg(test)]
318mod tests {
319    use mz_build_info::DUMMY_BUILD_INFO;
320    use mz_license_keys::ValidatedLicenseKey;
321    use serde_json::json;
322
323    use super::build_segment_traits;
324
325    fn license_key(expiration: u64, expired: bool) -> ValidatedLicenseKey {
326        ValidatedLicenseKey {
327            id: "test-license-key-id".into(),
328            organization: "test-organization-id".into(),
329            environment_id: "test-environment-id".into(),
330            expiration,
331            expired,
332            ..ValidatedLicenseKey::for_tests()
333        }
334    }
335
336    // Expiry is computed against the real wall clock, so these tests use
337    // expirations in the distant past (1, i.e. just after the Unix epoch) or
338    // distant future (`u64::MAX`) to stay deterministic.
339
340    #[mz_ore::test]
341    fn reports_license_and_version_traits() {
342        let mut traits = json!({"active_clusters": 1});
343        build_segment_traits(
344            traits.as_object_mut().unwrap(),
345            &license_key(u64::MAX, false),
346            &DUMMY_BUILD_INFO,
347            Some("25.1.0".into()),
348        );
349        assert_eq!(
350            traits,
351            json!({
352                "active_clusters": 1,
353                "organization_id": "test-organization-id",
354                "environment_id": "test-environment-id",
355                "license_key_id": "test-license-key-id",
356                "license_expiration_timestamp": u64::MAX,
357                "license_expired": false,
358                "license_expiration_behavior": "Warn",
359                "mz_version": DUMMY_BUILD_INFO.human_version(Some("25.1.0".into()))
360            })
361        );
362    }
363
364    #[mz_ore::test]
365    fn expiry_is_recomputed_from_wall_clock() {
366        // The key was valid at startup (`expired: false`) but the clock has
367        // since passed its expiration.
368        let mut traits = json!({});
369        build_segment_traits(
370            traits.as_object_mut().unwrap(),
371            &license_key(1, false),
372            &DUMMY_BUILD_INFO,
373            None,
374        );
375        assert_eq!(&traits["license_expired"], &json!(true));
376    }
377
378    #[mz_ore::test]
379    fn startup_expired_flag_is_preserved() {
380        // A key marked expired at startup stays expired even if the clock
381        // reads before its expiration.
382        let mut traits = json!({});
383        build_segment_traits(
384            traits.as_object_mut().unwrap(),
385            &license_key(u64::MAX, true),
386            &DUMMY_BUILD_INFO,
387            None,
388        );
389        assert_eq!(&traits["license_expired"], &json!(true));
390    }
391
392    #[mz_ore::test]
393    fn zero_expiration_sentinel_never_expires() {
394        let mut traits = json!({});
395        build_segment_traits(
396            traits.as_object_mut().unwrap(),
397            &license_key(0, false),
398            &DUMMY_BUILD_INFO,
399            None,
400        );
401        assert_eq!(&traits["license_expired"], &json!(false));
402    }
403}