Skip to content

Commit 237c7d0

Browse files
authored
[SVLS-8757] Add instance enhanced metric in Azure Functions (#114)
* Create datadog-metrics-collector crate to collect instance value with tags * Categorize metrics with azure.functions prefix as enhanced metrics * Use metrics collector in main loop and refactor start_dogstatsd * Change instance metric collection interval to 3, update comments * Add precondition for enhanced metrics collector in tokio select loop * Precompute tags in new() rather than building them in collect_and_submit(), change missing instance log to warn * Resolve instance ID based on hosting plan * Add unit test for Windows fallback * Use Tag::new() from libdd_common, make unknown a constant * Refactor build_enhanced_metrics_tags and add unit tests for build_tags * Rename Azure-specific files * Add unit tests for starting metrics components * Couple metrics aggregator and flusher together * Add clarifying comment * nit: fix formatting and Cargo.lock * Update datadog-metrics-collector libdatadog rev to d7eef8031192d0ee79ba64cd824804c5a57abacf
1 parent 3c5a041 commit 237c7d0

8 files changed

Lines changed: 592 additions & 96 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "datadog-metrics-collector"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
license.workspace = true
6+
description = "Collector to read, compute, and submit enhanced metrics in Serverless environments"
7+
8+
[dependencies]
9+
dogstatsd = { path = "../dogstatsd", default-features = true }
10+
tracing = { version = "0.1", default-features = false }
11+
libdd-common = { git = "https://github.com/DataDog/libdatadog", rev = "d7eef8031192d0ee79ba64cd824804c5a57abacf", default-features = false }
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Instance identity metric collector for Azure Functions.
5+
//!
6+
//! Submits `azure.functions.enhanced.instance` with value 1.0 on each
7+
//! collection tick, tagged with the instance identifier.
8+
9+
use dogstatsd::aggregator::AggregatorHandle;
10+
use dogstatsd::metric::{Metric, MetricValue, SortedTags};
11+
use std::env;
12+
use tracing::{error, warn};
13+
14+
const INSTANCE_METRIC: &str = "azure.functions.enhanced.instance";
15+
16+
/// Resolves the instance ID from explicit values (used by tests).
17+
///
18+
/// Picks the env var that matches the Azure integration metric's `instance`
19+
/// tag for the current hosting plan with fallback logic
20+
/// if the preferred source is empty.
21+
fn resolve_instance_id_from(
22+
website_sku: Option<&str>,
23+
container_name: Option<&str>,
24+
website_pod_name: Option<&str>,
25+
computer_name: Option<&str>,
26+
) -> Option<String> {
27+
fn non_empty(s: Option<&str>) -> Option<&str> {
28+
s.filter(|v| !v.is_empty())
29+
}
30+
31+
let sku_preferred = match website_sku {
32+
Some("FlexConsumption") | Some("Dynamic") => {
33+
non_empty(container_name).or(non_empty(website_pod_name))
34+
}
35+
Some(_) => non_empty(computer_name),
36+
None => None,
37+
};
38+
39+
sku_preferred
40+
.or_else(|| non_empty(container_name))
41+
.or_else(|| non_empty(website_pod_name))
42+
.or_else(|| non_empty(computer_name))
43+
.map(|s| s.to_lowercase())
44+
}
45+
46+
/// Resolves the instance ID from environment variables.
47+
fn resolve_instance_id() -> Option<String> {
48+
resolve_instance_id_from(
49+
env::var("WEBSITE_SKU").ok().as_deref(),
50+
env::var("CONTAINER_NAME").ok().as_deref(),
51+
env::var("WEBSITE_POD_NAME").ok().as_deref(),
52+
env::var("COMPUTERNAME").ok().as_deref(),
53+
)
54+
}
55+
56+
pub struct InstanceMetricsCollector {
57+
aggregator: AggregatorHandle,
58+
tags: Option<SortedTags>,
59+
}
60+
61+
impl InstanceMetricsCollector {
62+
/// Creates a new collector, returning `None` if no instance ID is found.
63+
pub fn new(aggregator: AggregatorHandle, tags: Option<SortedTags>) -> Option<Self> {
64+
let instance_id = resolve_instance_id();
65+
let Some(instance_id) = instance_id else {
66+
warn!("No instance ID found, instance metric will not be submitted");
67+
return None;
68+
};
69+
70+
// Precompute tags: enhanced metrics tags + instance tag
71+
let instance_tag = format!("instance:{}", instance_id);
72+
let tags = match tags {
73+
Some(mut existing) => {
74+
if let Ok(id_tag) = SortedTags::parse(&instance_tag) {
75+
existing.extend(&id_tag);
76+
}
77+
Some(existing)
78+
}
79+
None => SortedTags::parse(&instance_tag).ok(),
80+
};
81+
82+
Some(Self { aggregator, tags })
83+
}
84+
85+
pub fn collect_and_submit(&self) {
86+
let metric = Metric::new(
87+
INSTANCE_METRIC.into(),
88+
MetricValue::gauge(1.0),
89+
self.tags.clone(),
90+
None,
91+
);
92+
93+
if let Err(e) = self.aggregator.insert_batch(vec![metric]) {
94+
error!("Failed to insert instance metric: {}", e);
95+
}
96+
}
97+
}
98+
99+
#[cfg(test)]
100+
mod tests {
101+
use super::*;
102+
103+
#[test]
104+
fn test_flex_consumption_uses_container_name() {
105+
let id = resolve_instance_id_from(
106+
Some("FlexConsumption"),
107+
Some("0--abc-DEF"),
108+
Some("0--abc-DEF"),
109+
None,
110+
);
111+
assert_eq!(id, Some("0--abc-def".to_string()));
112+
}
113+
114+
#[test]
115+
fn test_flex_consumption_falls_back_to_pod_name_if_container_missing() {
116+
let id = resolve_instance_id_from(Some("FlexConsumption"), None, Some("pod-XYZ"), None);
117+
assert_eq!(id, Some("pod-xyz".to_string()));
118+
}
119+
120+
#[test]
121+
fn test_consumption_uses_container_name() {
122+
let id = resolve_instance_id_from(
123+
Some("Dynamic"),
124+
Some("ABCD1234-111122223333444455"),
125+
None,
126+
None,
127+
);
128+
assert_eq!(id, Some("abcd1234-111122223333444455".to_string()));
129+
}
130+
131+
#[test]
132+
fn test_elastic_premium_uses_computer_name() {
133+
let id =
134+
resolve_instance_id_from(Some("ElasticPremium"), None, None, Some("ep0fakewk0000A1"));
135+
assert_eq!(id, Some("ep0fakewk0000a1".to_string()));
136+
}
137+
138+
#[test]
139+
fn test_dedicated_uses_computer_name() {
140+
let id = resolve_instance_id_from(Some("PremiumV3"), None, None, Some("p3fakewk0000B2"));
141+
assert_eq!(id, Some("p3fakewk0000b2".to_string()));
142+
}
143+
144+
#[test]
145+
fn test_empty_string_is_treated_as_missing() {
146+
let id =
147+
resolve_instance_id_from(Some("ElasticPremium"), Some(""), Some(""), Some("worker-1"));
148+
assert_eq!(id, Some("worker-1".to_string()));
149+
}
150+
151+
#[test]
152+
fn test_unknown_sku_falls_back_to_search_order() {
153+
let id = resolve_instance_id_from(Some("SomeNewSku"), Some("container-1"), None, None);
154+
assert_eq!(id, Some("container-1".to_string()));
155+
}
156+
157+
#[test]
158+
fn test_missing_sku_falls_back_to_search_order() {
159+
let id = resolve_instance_id_from(None, Some("container-1"), None, Some("worker-1"));
160+
assert_eq!(id, Some("container-1".to_string()));
161+
}
162+
163+
#[test]
164+
fn test_no_env_vars_returns_none() {
165+
let id = resolve_instance_id_from(None, None, None, None);
166+
assert_eq!(id, None);
167+
}
168+
169+
// On Windows Consumption we've observed CONTAINER_NAME and WEBSITE_POD_NAME
170+
// unset but COMPUTERNAME set
171+
#[test]
172+
fn test_windows_consumption_falls_through_to_computer_name() {
173+
let id = resolve_instance_id_from(Some("Dynamic"), None, None, Some("10-20-30-40"));
174+
assert_eq!(id, Some("10-20-30-40".to_string()));
175+
}
176+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Shared tag builder for enhanced metrics.
5+
//!
6+
//! Tags are attached to all enhanced metrics submitted by the metrics collector.
7+
8+
use dogstatsd::metric::SortedTags;
9+
use libdd_common::{azure_app_services, tag::Tag};
10+
use std::env;
11+
use tracing::warn;
12+
13+
/// `libdd_common::azure_app_services` returns this value when the corresponding Azure metadata isn't populated.
14+
const AAS_UNKNOWN_VALUE: &str = "unknown";
15+
16+
/// Builds the common tags for all enhanced metrics.
17+
///
18+
/// Sources:
19+
/// - Azure metadata (resource_group, subscription_id, name) from libdd_common
20+
/// - Environment variables (region, plan_tier, service, env, version, serverless_compat_version)
21+
///
22+
/// The DogStatsD origin tag (e.g. `origin:azurefunction`) is added by the metrics aggregator,
23+
/// not here.
24+
pub fn build_enhanced_metrics_tags() -> Option<SortedTags> {
25+
let mut pairs: Vec<(&'static str, String)> = Vec::new();
26+
27+
if let Some(aas_metadata) = &*azure_app_services::AAS_METADATA_FUNCTION {
28+
for (name, value) in [
29+
("resource_group", aas_metadata.get_resource_group()),
30+
("subscription_id", aas_metadata.get_subscription_id()),
31+
("name", aas_metadata.get_site_name()),
32+
] {
33+
if value != AAS_UNKNOWN_VALUE {
34+
pairs.push((name, value.to_string()));
35+
}
36+
}
37+
}
38+
39+
for (tag_name, env_var) in [
40+
("region", "REGION_NAME"),
41+
("plan_tier", "WEBSITE_SKU"),
42+
("service", "DD_SERVICE"),
43+
("env", "DD_ENV"),
44+
("version", "DD_VERSION"),
45+
("serverless_compat_version", "DD_SERVERLESS_COMPAT_VERSION"),
46+
] {
47+
if let Ok(val) = env::var(env_var) {
48+
pairs.push((tag_name, val));
49+
}
50+
}
51+
52+
build_tags(pairs)
53+
}
54+
55+
fn build_tags(pairs: impl IntoIterator<Item = (&'static str, String)>) -> Option<SortedTags> {
56+
let mut tags: Vec<Tag> = Vec::new();
57+
for (key, value) in pairs {
58+
if value.is_empty() {
59+
continue;
60+
}
61+
// Tag::new validates the combined "key:value" string: it must be
62+
// non-empty and not start or end with a colon
63+
match Tag::new(key, &value) {
64+
Ok(t) => tags.push(t),
65+
Err(e) => warn!("Skipping invalid tag {key}:{value}: {e}"),
66+
}
67+
}
68+
if tags.is_empty() {
69+
return None;
70+
}
71+
let joined = tags
72+
.iter()
73+
.map(|t| t.as_ref())
74+
.collect::<Vec<&str>>()
75+
.join(",");
76+
SortedTags::parse(&joined).ok()
77+
}
78+
79+
#[cfg(test)]
80+
mod tests {
81+
use super::*;
82+
83+
#[test]
84+
fn test_build_tags_returns_none_when_no_pairs() {
85+
let pairs: Vec<(&'static str, String)> = Vec::new();
86+
assert!(build_tags(pairs).is_none());
87+
}
88+
89+
#[test]
90+
fn test_build_tags_returns_none_when_all_values_empty() {
91+
let pairs = vec![("service", String::new()), ("env", String::new())];
92+
assert!(build_tags(pairs).is_none());
93+
}
94+
95+
#[test]
96+
fn test_build_tags_skips_empty_values() {
97+
let pairs = vec![("service", String::new()), ("env", "dev".to_string())];
98+
let tags = build_tags(pairs).unwrap().to_strings();
99+
assert_eq!(tags, vec!["env:dev"]);
100+
}
101+
102+
#[test]
103+
fn test_build_tags_includes_all_nonempty_pairs() {
104+
let pairs = vec![
105+
("service", "svc-1".to_string()),
106+
("env", "dev".to_string()),
107+
("version", "1.2.3".to_string()),
108+
];
109+
let mut tags = build_tags(pairs).unwrap().to_strings();
110+
tags.sort();
111+
assert_eq!(tags, vec!["env:dev", "service:svc-1", "version:1.2.3"]);
112+
}
113+
114+
#[test]
115+
fn test_build_tags_rejects_trailing_colon_values() {
116+
let pairs = vec![
117+
("service", "svc-1:".to_string()),
118+
("env", "dev".to_string()),
119+
];
120+
let tags = build_tags(pairs).unwrap().to_strings();
121+
assert_eq!(tags, vec!["env:dev"]);
122+
}
123+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#![cfg_attr(not(test), deny(clippy::panic))]
5+
#![cfg_attr(not(test), deny(clippy::unwrap_used))]
6+
#![cfg_attr(not(test), deny(clippy::expect_used))]
7+
#![cfg_attr(not(test), deny(clippy::todo))]
8+
#![cfg_attr(not(test), deny(clippy::unimplemented))]
9+
10+
pub mod azure_instance;
11+
pub mod azure_tags;

crates/datadog-serverless-compat/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ windows-pipes = ["datadog-trace-agent/windows-pipes", "dogstatsd/windows-pipes"]
1111

1212
[dependencies]
1313
datadog-logs-agent = { path = "../datadog-logs-agent" }
14+
datadog-metrics-collector = { path = "../datadog-metrics-collector" }
1415
datadog-trace-agent = { path = "../datadog-trace-agent" }
1516
libdd-trace-utils = { git = "https://github.com/DataDog/libdatadog", rev = "d7eef8031192d0ee79ba64cd824804c5a57abacf" }
1617
datadog-fips = { path = "../datadog-fips", default-features = false }

0 commit comments

Comments
 (0)