Skip to content

Commit 5708168

Browse files
committed
feat(telemetry): implement telemetry data collection and reporting
1 parent 389f3b3 commit 5708168

8 files changed

Lines changed: 450 additions & 149 deletions

File tree

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,19 @@
4949
# Base URL for proxied Pixiv images (replaces https://i.pximg.net)
5050
# Example: Set to https://your-domain.com/pixiv-proxy to use your own proxy
5151
# PIXIV_PROXY_BASE_URL=https://your-domain.com/pixiv-proxy
52+
53+
# Telemetry Configuration
54+
# Enable/disable telemetry data collection (default: false)
55+
# TELEMETRY_ENABLED=false
56+
57+
# Telemetry endpoint URL (required if telemetry is enabled)
58+
# TELEMETRY_ENDPOINT=https://your-telemetry-endpoint.com/api/collect
59+
60+
# Bearer token for telemetry endpoint authentication (required if telemetry is enabled)
61+
# TELEMETRY_BEARER_TOKEN=your_bearer_token_here
62+
63+
# Telemetry collects:
64+
# - Query object (e.g., domain name, IP address)
65+
# - Query type (e.g., domain, ipv4, asn)
66+
# - Client IP address
67+
# - Response time in milliseconds

src/core/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod patch;
33
pub mod query;
44
pub mod query_processor;
55
pub mod stats;
6+
pub mod telemetry;
67
pub mod utils;
78

89
pub use color::*;

src/core/query_processor.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,14 @@ use crate::services::{
9898
pub async fn process_query(
9999
query: &str,
100100
query_type: &QueryType,
101-
color_scheme: Option<ColorScheme>
101+
color_scheme: Option<ColorScheme>,
102+
client_ip: Option<String>
102103
) -> Result<String> {
103104
debug!("Processing query: {} (type: {:?})", query, query_type);
104105

106+
// Start timing the query
107+
let start_time = std::time::Instant::now();
108+
105109
// Process the query based on its type
106110
let result = match query_type {
107111
QueryType::Domain(domain) => {
@@ -414,6 +418,24 @@ pub async fn process_query(
414418
}
415419
};
416420

421+
// Calculate response time
422+
let response_time = start_time.elapsed().as_millis() as u64;
423+
424+
// Send telemetry data if client IP is provided
425+
if let Some(ip) = client_ip {
426+
let query_object = query.to_string();
427+
let query_type_str = crate::core::telemetry::query_type_to_string(query_type);
428+
429+
let telemetry_data = crate::core::telemetry::TelemetryData::new(
430+
query_object,
431+
query_type_str,
432+
ip,
433+
response_time
434+
);
435+
436+
crate::core::telemetry::send_telemetry(telemetry_data).await;
437+
}
438+
417439
// Apply colorization if scheme is provided, then apply patches
418440
match result {
419441
Ok(response) => {

src/core/telemetry.rs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// WHOIS Server - Telemetry Module
2+
// Copyright (C) 2025 Akaere Networks
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
//! Telemetry collection module for query analytics
6+
7+
use serde::{ Deserialize, Serialize };
8+
use std::sync::OnceLock;
9+
use tracing::{ debug, warn };
10+
11+
/// HTTP request timeout in seconds
12+
const REQUEST_TIMEOUT_SECS: u64 = 5;
13+
/// Maximum time to wait for telemetry task completion before discarding (in seconds)
14+
const TELEMETRY_TASK_TIMEOUT_SECS: u64 = 10;
15+
16+
/// Cached telemetry configuration
17+
static TELEMETRY_CONFIG: OnceLock<TelemetryConfig> = OnceLock::new();
18+
19+
/// Telemetry configuration loaded from environment variables
20+
///
21+
/// Environment variables:
22+
/// - `TELEMETRY_ENABLED`: Set to "true" or "1" to enable telemetry (default: false)
23+
/// - `TELEMETRY_ENDPOINT`: The endpoint URL to send telemetry data to (required if enabled)
24+
/// - `TELEMETRY_BEARER_TOKEN`: The bearer token for authentication (required if enabled)
25+
#[derive(Debug, Clone)]
26+
struct TelemetryConfig {
27+
enabled: bool,
28+
endpoint: Option<String>,
29+
bearer_token: Option<String>,
30+
}
31+
32+
impl TelemetryConfig {
33+
fn from_env() -> Self {
34+
let enabled = std::env
35+
::var("TELEMETRY_ENABLED")
36+
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1"))
37+
.unwrap_or(false); // Default: disabled
38+
39+
let endpoint = std::env::var("TELEMETRY_ENDPOINT").ok();
40+
let bearer_token = std::env::var("TELEMETRY_BEARER_TOKEN").ok();
41+
42+
// Warn if enabled but missing required config
43+
if enabled {
44+
if endpoint.is_none() {
45+
warn!("TELEMETRY_ENABLED is true but TELEMETRY_ENDPOINT is not set");
46+
}
47+
if bearer_token.is_none() {
48+
warn!("TELEMETRY_ENABLED is true but TELEMETRY_BEARER_TOKEN is not set");
49+
}
50+
}
51+
52+
Self {
53+
enabled,
54+
endpoint,
55+
bearer_token,
56+
}
57+
}
58+
59+
fn is_valid(&self) -> bool {
60+
self.enabled && self.endpoint.is_some() && self.bearer_token.is_some()
61+
}
62+
}
63+
64+
fn get_config() -> &'static TelemetryConfig {
65+
TELEMETRY_CONFIG.get_or_init(TelemetryConfig::from_env)
66+
}
67+
68+
/// Telemetry data structure
69+
#[derive(Debug, Serialize, Deserialize)]
70+
pub struct TelemetryData {
71+
pub query_object: String,
72+
pub query_type: String,
73+
pub client_ip: String,
74+
pub response_time: u64,
75+
}
76+
77+
impl TelemetryData {
78+
/// Create a new telemetry data instance
79+
pub fn new(
80+
query_object: String,
81+
query_type: String,
82+
client_ip: String,
83+
response_time: u64
84+
) -> Self {
85+
Self {
86+
query_object,
87+
query_type,
88+
client_ip,
89+
response_time,
90+
}
91+
}
92+
}
93+
94+
/// Send telemetry data to the collection endpoint
95+
pub async fn send_telemetry(data: TelemetryData) {
96+
let config = get_config();
97+
98+
// Check if telemetry is enabled and properly configured
99+
if !config.is_valid() {
100+
debug!("Telemetry is disabled or not configured, skipping");
101+
return;
102+
}
103+
104+
// Run telemetry in background with timeout to avoid blocking
105+
let handle = tokio::spawn(async move {
106+
if let Err(e) = send_telemetry_internal(data).await {
107+
warn!("Failed to send telemetry data: {}", e);
108+
}
109+
});
110+
111+
// Set a timeout for the telemetry task - if it takes too long, just discard it
112+
tokio::spawn(async move {
113+
match
114+
tokio::time::timeout(
115+
std::time::Duration::from_secs(TELEMETRY_TASK_TIMEOUT_SECS),
116+
handle
117+
).await
118+
{
119+
Ok(_) => {
120+
// Task completed within timeout
121+
}
122+
Err(_) => {
123+
warn!("Telemetry task timed out after {}s, discarding", TELEMETRY_TASK_TIMEOUT_SECS);
124+
}
125+
}
126+
});
127+
}
128+
129+
/// Internal function to send telemetry data
130+
async fn send_telemetry_internal(data: TelemetryData) -> Result<(), anyhow::Error> {
131+
let config = get_config();
132+
133+
// Safe to unwrap since is_valid() was checked before calling this
134+
let endpoint = config.endpoint.as_ref().unwrap();
135+
let bearer_token = config.bearer_token.as_ref().unwrap();
136+
137+
debug!(
138+
"Sending telemetry: query={}, type={}, ip={}, time={}ms",
139+
data.query_object,
140+
data.query_type,
141+
data.client_ip,
142+
data.response_time
143+
);
144+
145+
let client = reqwest::Client
146+
::builder()
147+
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
148+
.build()?;
149+
150+
let response = client
151+
.post(endpoint)
152+
.header("Authorization", format!("Bearer {}", bearer_token))
153+
.header("Content-Type", "application/json")
154+
.header("User-Agent", "Akaere-Networks-Whois")
155+
.json(&data)
156+
.send().await?;
157+
158+
if !response.status().is_success() {
159+
warn!("Telemetry endpoint returned error status: {}", response.status());
160+
} else {
161+
debug!("Telemetry data sent successfully");
162+
}
163+
164+
Ok(())
165+
}
166+
167+
/// Convert QueryType to string representation for telemetry
168+
pub fn query_type_to_string(query_type: &crate::core::QueryType) -> String {
169+
match query_type {
170+
crate::core::QueryType::Domain(_) => "domain".to_string(),
171+
crate::core::QueryType::IPv4(_) => "ipv4".to_string(),
172+
crate::core::QueryType::IPv6(_) => "ipv6".to_string(),
173+
crate::core::QueryType::ASN(_) => "asn".to_string(),
174+
crate::core::QueryType::EmailSearch(_) => "email_search".to_string(),
175+
crate::core::QueryType::BGPTool(_) => "bgptool".to_string(),
176+
crate::core::QueryType::Geo(_) => "geo".to_string(),
177+
crate::core::QueryType::RirGeo(_) => "rir_geo".to_string(),
178+
crate::core::QueryType::Prefixes(_) => "prefixes".to_string(),
179+
crate::core::QueryType::Radb(_) => "radb".to_string(),
180+
crate::core::QueryType::Altdb(_) => "altdb".to_string(),
181+
crate::core::QueryType::Afrinic(_) => "afrinic".to_string(),
182+
crate::core::QueryType::Apnic(_) => "apnic".to_string(),
183+
crate::core::QueryType::ArinIrr(_) => "arin_irr".to_string(),
184+
crate::core::QueryType::Bell(_) => "bell".to_string(),
185+
crate::core::QueryType::Jpirr(_) => "jpirr".to_string(),
186+
crate::core::QueryType::Lacnic(_) => "lacnic".to_string(),
187+
crate::core::QueryType::Level3(_) => "level3".to_string(),
188+
crate::core::QueryType::Nttcom(_) => "nttcom".to_string(),
189+
crate::core::QueryType::RipeIrr(_) => "ripe_irr".to_string(),
190+
crate::core::QueryType::Ris(_) => "ris".to_string(),
191+
crate::core::QueryType::Tc(_) => "tc".to_string(),
192+
crate::core::QueryType::Irr(_) => "irr".to_string(),
193+
crate::core::QueryType::LookingGlass(_) => "looking_glass".to_string(),
194+
crate::core::QueryType::Rpki(_, _) => "rpki".to_string(),
195+
crate::core::QueryType::Manrs(_) => "manrs".to_string(),
196+
crate::core::QueryType::Dns(_) => "dns".to_string(),
197+
crate::core::QueryType::Trace(_) => "traceroute".to_string(),
198+
crate::core::QueryType::Ssl(_) => "ssl".to_string(),
199+
crate::core::QueryType::Crt(_) => "certificate_transparency".to_string(),
200+
crate::core::QueryType::CfStatus(_) => "cloudflare_status".to_string(),
201+
crate::core::QueryType::Minecraft(_) => "minecraft".to_string(),
202+
crate::core::QueryType::MinecraftUser(_) => "minecraft_user".to_string(),
203+
crate::core::QueryType::Steam(_) => "steam".to_string(),
204+
crate::core::QueryType::SteamSearch(_) => "steam_search".to_string(),
205+
crate::core::QueryType::Imdb(_) => "imdb".to_string(),
206+
crate::core::QueryType::ImdbSearch(_) => "imdb_search".to_string(),
207+
crate::core::QueryType::Acgc(_) => "acgc".to_string(),
208+
crate::core::QueryType::Alma(_) => "alma".to_string(),
209+
crate::core::QueryType::Aosc(_) => "aosc".to_string(),
210+
crate::core::QueryType::Aur(_) => "aur".to_string(),
211+
crate::core::QueryType::Debian(_) => "debian".to_string(),
212+
crate::core::QueryType::Epel(_) => "epel".to_string(),
213+
crate::core::QueryType::Ubuntu(_) => "ubuntu".to_string(),
214+
crate::core::QueryType::NixOs(_) => "nixos".to_string(),
215+
crate::core::QueryType::OpenSuse(_) => "opensuse".to_string(),
216+
crate::core::QueryType::OpenWrt(_) => "openwrt".to_string(),
217+
crate::core::QueryType::Npm(_) => "npm".to_string(),
218+
crate::core::QueryType::Pypi(_) => "pypi".to_string(),
219+
crate::core::QueryType::Cargo(_) => "cargo".to_string(),
220+
crate::core::QueryType::Modrinth(_) => "modrinth".to_string(),
221+
crate::core::QueryType::CurseForge(_) => "curseforge".to_string(),
222+
crate::core::QueryType::GitHub(_) => "github".to_string(),
223+
crate::core::QueryType::Wikipedia(_) => "wikipedia".to_string(),
224+
crate::core::QueryType::Lyric(_) => "lyric".to_string(),
225+
crate::core::QueryType::Desc(_) => "description".to_string(),
226+
crate::core::QueryType::PeeringDB(_) => "peeringdb".to_string(),
227+
crate::core::QueryType::Pen(_) => "pen".to_string(),
228+
crate::core::QueryType::Rdap(_) => "rdap".to_string(),
229+
crate::core::QueryType::Pixiv(_) => "pixiv".to_string(),
230+
crate::core::QueryType::Meal => "meal".to_string(),
231+
crate::core::QueryType::MealCN => "meal_cn".to_string(),
232+
crate::core::QueryType::Ntp(_) => "ntp".to_string(),
233+
crate::core::QueryType::Help => "help".to_string(),
234+
crate::core::QueryType::UpdatePatch => "update_patch".to_string(),
235+
crate::core::QueryType::Unknown(_) => "unknown".to_string(),
236+
}
237+
}
238+
239+
#[cfg(test)]
240+
mod tests {
241+
use super::*;
242+
243+
#[test]
244+
fn test_telemetry_data_creation() {
245+
let data = TelemetryData::new(
246+
"example.com".to_string(),
247+
"domain".to_string(),
248+
"1.2.3.4".to_string(),
249+
150
250+
);
251+
252+
assert_eq!(data.query_object, "example.com");
253+
assert_eq!(data.query_type, "domain");
254+
assert_eq!(data.client_ip, "1.2.3.4");
255+
assert_eq!(data.response_time, 150);
256+
}
257+
258+
#[test]
259+
fn test_query_type_to_string() {
260+
use crate::core::QueryType;
261+
262+
assert_eq!(query_type_to_string(&QueryType::Domain("example.com".to_string())), "domain");
263+
assert_eq!(query_type_to_string(&QueryType::Help), "help");
264+
}
265+
}

src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub mod web;
7272

7373
// Re-export commonly used types for convenience
7474
pub use core::query_processor::process_query;
75-
pub use core::{ColorScheme, QueryType, analyze_query};
75+
pub use core::{ ColorScheme, QueryType, analyze_query };
7676

7777
/// Simple API for querying WHOIS information
7878
///
@@ -103,7 +103,7 @@ pub use core::{ColorScheme, QueryType, analyze_query};
103103
/// ```
104104
pub async fn query(input: &str) -> anyhow::Result<String> {
105105
let query_type = analyze_query(input);
106-
process_query(input, &query_type, None).await
106+
process_query(input, &query_type, None, None).await
107107
}
108108

109109
/// Query with color scheme support
@@ -124,8 +124,8 @@ pub async fn query(input: &str) -> anyhow::Result<String> {
124124
/// ```
125125
pub async fn query_with_color(
126126
input: &str,
127-
color_scheme: Option<ColorScheme>,
127+
color_scheme: Option<ColorScheme>
128128
) -> anyhow::Result<String> {
129129
let query_type = analyze_query(input);
130-
process_query(input, &query_type, color_scheme).await
130+
process_query(input, &query_type, color_scheme, None).await
131131
}

0 commit comments

Comments
 (0)