1use 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#[derive(Clone)]
99pub struct Config {
100 pub segment_client: mz_segment::Client,
102 pub adapter_client: mz_adapter::Client,
104 pub environment_id: EnvironmentId,
106 pub license_key: ValidatedLicenseKey,
110 pub helm_chart_version: Option<String>,
113 pub report_interval: Duration,
115}
116
117pub 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 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
277fn 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 #[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 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 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}