From 23998fd32b600d9b9c87eeb18f96a678f9efc00b Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:48:46 +0100 Subject: [PATCH 1/6] feat: add whole app root span and label to differ run duration on gather event --- src/main.rs | 82 +++++++++++++++++++++++++++----------------------- src/metrics.rs | 6 +++- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/main.rs b/src/main.rs index a30e99a..f635663 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,9 @@ use alertaemcena::discord::api::{DiscordAPI, EventsThread}; use alertaemcena::discord::backup::{backup_user_votes, VoteRecord}; use alertaemcena::metrics::{ record_event_send_duration, record_event_sent, record_events_fetched, record_pipeline_error, - record_pipeline_run_duration, record_reaction_processing_duration, record_vote_backup_duration, - record_vote_backup_records, set_threads_active, MetricResult, PipelineErrorKind, PipelineStage, + record_pipeline_run_duration, record_pipeline_run_duration_without_event_gather, + record_reaction_processing_duration, record_vote_backup_duration, record_vote_backup_records, + set_threads_active, MetricResult, PipelineErrorKind, PipelineStage, }; use alertaemcena::tracing::setup_tracing; use chrono::Utc; @@ -35,49 +36,56 @@ async fn main() { { let _shutdown_hook = ShutdownHook; - let config = load_config(); + let root_span = info_span!("run"); - debug!("Loaded {:?}", config); + async move { + let config = load_config(); - let discord = DiscordAPI::default().await; + debug!("Loaded {:?}", config); - if config.debug_config.clear_channel { - discord.delete_all_messages(&config.teatro_channel_id).await; - discord.delete_all_messages(&config.artes_channel_id).await; + let discord = DiscordAPI::default().await; - if config.debug_config.exit_after_clearing { - exit(0) - } - } + if config.debug_config.clear_channel { + discord.delete_all_messages(&config.teatro_channel_id).await; + discord.delete_all_messages(&config.artes_channel_id).await; - let mut users_to_backup = Vec::new(); + if config.debug_config.exit_after_clearing { + exit(0) + } + } - if !config.debug_config.skip_artes { - run(&config, &discord, Category::Artes, config.artes_channel_id) - .instrument(info_span!("pipeline", category = "Artes")) - .await - .iter() - .for_each(|u| { - users_to_backup.push(*u); - }) - } + let mut users_to_backup = Vec::new(); - run( - &config, - &discord, - Category::Teatro, - config.teatro_channel_id, - ) - .instrument(info_span!("pipeline", category = "Teatro")) - .await - .iter() - .for_each(|u| { - if !users_to_backup.contains(u) { - users_to_backup.push(*u); + if !config.debug_config.skip_artes { + run(&config, &discord, Category::Artes, config.artes_channel_id) + .instrument(info_span!("pipeline", category = "Artes")) + .await + .iter() + .for_each(|u| { + users_to_backup.push(*u); + }) } - }); - backup_votes(&discord, users_to_backup).await; + run( + &config, + &discord, + Category::Teatro, + config.teatro_channel_id, + ) + .instrument(info_span!("pipeline", category = "Teatro")) + .await + .iter() + .for_each(|u| { + if !users_to_backup.contains(u) { + users_to_backup.push(*u); + } + }); + + backup_votes(&discord, users_to_backup).await; + info!("Starting app"); + } + .instrument(root_span) + .await; } tracing_handles.shutdown().await; @@ -107,7 +115,7 @@ async fn run( if !config.gather_new_events { info!("Set to not gather new events"); - record_pipeline_run_duration(&category, pipeline_started_at.elapsed()); + record_pipeline_run_duration_without_event_gather(&category, pipeline_started_at.elapsed()); return users_with_reactions; } diff --git a/src/metrics.rs b/src/metrics.rs index 7186d50..44f6723 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -191,8 +191,12 @@ pub fn record_vote_backup_duration(result: MetricResult, duration: Duration) { VOTE_BACKUP_DURATION_SECONDS.record(duration.as_secs_f64(), &[result.into()]); } +pub fn record_pipeline_run_duration_without_event_gather(category: &Category, duration: Duration) { + PIPELINE_RUN_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into(), KeyValue::new("gathered_events", false)]); +} + pub fn record_pipeline_run_duration(category: &Category, duration: Duration) { - PIPELINE_RUN_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into()]); + PIPELINE_RUN_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into(), KeyValue::new("gathered_events", true)]); } pub fn record_pipeline_error(stage: PipelineStage, error_kind: PipelineErrorKind) { From 168cc47a1f248a717ee1a2c68b80fa68c980d3b3 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:49:34 +0100 Subject: [PATCH 2/6] refactor: remove unnecessary span --- src/agenda_cultural/dto.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/agenda_cultural/dto.rs b/src/agenda_cultural/dto.rs index f50432f..156a4a5 100644 --- a/src/agenda_cultural/dto.rs +++ b/src/agenda_cultural/dto.rs @@ -8,12 +8,6 @@ use serde_json::Value; use std::collections::{BTreeMap, HashSet}; use tracing::warn; -#[derive(Debug, Deserialize)] -pub struct SingleEventResponse { - #[serde(rename = "data")] - pub event: EventResponse, -} - // Note: some String fields need the custom deserializer due to being optional #[derive(Debug, Deserialize)] pub struct EventResponse { @@ -39,7 +33,6 @@ lazy_static! { } impl EventResponse { - #[tracing::instrument(skip(self), fields(self.link = %self.link))] pub async fn to_model(&self, description: String) -> Event { let subtitle = match self.subtitle.clone() { SingleOrVec::Single(subtitle) => subtitle, From 1b6ed5ca0d34e36cb807ff795ce767ee240b79dd Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:50:28 +0100 Subject: [PATCH 3/6] refactor: remove function only used in tests --- src/agenda_cultural/api.rs | 78 +++----------------- tests/agenda_cultural_api_tests.rs | 112 ----------------------------- 2 files changed, 10 insertions(+), 180 deletions(-) diff --git a/src/agenda_cultural/api.rs b/src/agenda_cultural/api.rs index f0e4439..4a5e620 100644 --- a/src/agenda_cultural/api.rs +++ b/src/agenda_cultural/api.rs @@ -1,5 +1,4 @@ use super::{dto::EventResponse, model::Event}; -use crate::agenda_cultural::dto::SingleEventResponse; use crate::agenda_cultural::model::Category; use chrono::{Datelike, NaiveDate, TimeDelta, Utc}; use futures::TryFutureExt; @@ -14,11 +13,11 @@ use std::cmp::Ordering; use std::collections::BTreeMap; use std::ops::Add; use std::time::Duration; -use tracing::{debug, info, instrument, trace, warn}; +use strum::Display; +use tracing::{debug, error, info, instrument, trace, warn}; use voca_rs::strip::strip_tags; const AGENDA_EVENTS_URL: &str = "https://www.agendalx.pt/wp-json/agendalx/v1/events"; -const AGENDA_PAGE_BY_ID_PATH: &str = "https://www.agendalx.pt/?p="; const EVENT_TYPE: &str = "event"; const DATE_PRINT_FORMAT: &str = "%Y-%m-%d"; @@ -31,11 +30,6 @@ lazy_static! { .build_with_total_retry_duration_and_max_retries(Duration::from_secs(30)) )) .build(); - static ref EVENT_ID_SELECTOR: Selector = Selector::parse(&format!( - r#"link[rel="shortlink"][href^="{}"]"#, - AGENDA_PAGE_BY_ID_PATH - )) - .unwrap(); static ref EVENT_DESCRIPTION_SELECTOR: Selector = Selector::parse(".entry-container > :not(.event__extra-info):not(.section-title):not(.section-title--venue):not(.venue):not(.post__share)").unwrap(); } @@ -47,7 +41,7 @@ impl AgendaCulturalAPI { Returns events in ascending order * amount_per_page: if not specified, will retrieve everything */ - #[tracing::instrument] + #[instrument] pub async fn get_events_by_month( category: &Category, amount_per_page: Option, @@ -62,7 +56,11 @@ impl AgendaCulturalAPI { } let category: &'static str = category.into(); - let parsed_response = Self::get_events_by_category(amount_per_page, category).await?; + let parsed_response = Self::get_events_by_category(amount_per_page, category) + .await + .inspect_err(|err| { + error!("Failed to get events by category '{}': {}", category, err) + })?; info!("Fetched {} events", parsed_response.len()); @@ -149,63 +147,7 @@ impl AgendaCulturalAPI { response.to_model(description).await } - /** - Returns the specified event - */ - #[tracing::instrument] - pub async fn get_event_by_id(event_id: u32) -> Result { - let json_response = REST_CLIENT - .get(format!("{}/{}", AGENDA_EVENTS_URL, event_id)) - .send() - .await - .map_err(APIError::ErrorSending)? - .error_for_status() - .map_err(APIError::ResponseError)? - .text() - .map_err(APIError::InvalidResponse) - .await?; - trace!("Json response: {json_response}"); - - let parsed_response = serde_json::from_str::(&json_response) - .map_err(APIError::ParseError)?; - - Ok(Self::convert_response_to_model(&parsed_response.event).await) - } - - /** - Returns the specified event - */ - #[tracing::instrument] - pub async fn get_event_by_public_url(url: &str) -> Result { - let full_page: String = REST_CLIENT - .get(url) - .send() - .map_err(APIError::ErrorSending) - .await? - .error_for_status() - .map_err(APIError::ResponseError)? - .text() - .map_err(APIError::InvalidResponse) - .await?; - let page_html = Html::parse_fragment(&full_page); - let id_element = page_html - .select(&EVENT_ID_SELECTOR) - .next() - .ok_or_else(|| { - APIError::FailedParsingHtml("Could not find ID element in page!".to_string()) - })? - .attr("href") - .and_then(|href| href.strip_prefix(AGENDA_PAGE_BY_ID_PATH)) - .ok_or_else(|| { - APIError::FailedParsingHtml("Could not find ID in the element!".to_string()) - })? - .parse() - .map_err(|_| APIError::FailedParsingHtml("Fetched ID is not valid!".to_string()))?; - - Self::get_event_by_id(id_element).await - } - - #[tracing::instrument] + #[instrument] async fn get_events_by_category( amount_per_page: Option, category: &str, @@ -377,7 +319,7 @@ mod tests { } } -#[derive(Debug)] +#[derive(Debug, Display)] pub enum APIError { ErrorSending(reqwest_middleware::Error), ResponseError(reqwest::Error), diff --git a/tests/agenda_cultural_api_tests.rs b/tests/agenda_cultural_api_tests.rs index ddbe52a..3559f51 100644 --- a/tests/agenda_cultural_api_tests.rs +++ b/tests/agenda_cultural_api_tests.rs @@ -25,116 +25,4 @@ mod agenda_cultural { assert_eq!(res.len(), 2); } - - #[test_log::test(tokio::test)] - async fn should_scrape_the_specified_event_by_public_url() { - let event: Event = AgendaCulturalAPI::get_event_by_public_url( - "https://www.agendalx.pt/events/event/nora-helmer/", - ) - .await - .unwrap(); - - assert_eq!(event.title, "Nora Helmer"); - assert!(event.details.description.starts_with("A história de Nora Helmer, protagonista de Casa de Bonecas, peça de Henrik Ibsen, torna-se o ponto de partida para um debate aceso sobre a família")); - assert_eq!(event.details.image_url, "https://www.agendalx.pt/content/uploads/2025/02/Nora-Helmer_ensaios2©Filipe_Figueiredo.jpg"); - assert_eq!( - event.details.subtitle, - "A partir de Henrik Ibsen e Lucas Hnath" - ); - assert_eq!( - event.link, - "https://www.agendalx.pt/events/event/nora-helmer/" - ); - assert_eq!(event.occurring_at.dates, "8 março a 11 maio"); - assert_eq!( - event.occurring_at.times, - "qua: 19h; qui: 19h; sex: 21h; sáb: 21h; dom: 16h" - ); - assert_eq!(event.venue, "Teatro Aberto"); - assert!(event.tags.is_empty()); - } - - #[test_log::test(tokio::test)] - async fn should_scrape_the_specified_event_by_id() { - let event: Event = AgendaCulturalAPI::get_event_by_id(208058).await.unwrap(); - - assert_eq!(event.title, "Nora Helmer"); - assert!(event.details.description.starts_with( - "A história de Nora Helmer, protagonista de Casa de Bonecas, peça de Henrik Ibsen" - )); - assert_eq!(event.details.image_url, "https://www.agendalx.pt/content/uploads/2025/02/Nora-Helmer_ensaios2©Filipe_Figueiredo.jpg"); - assert_eq!( - event.details.subtitle, - "A partir de Henrik Ibsen e Lucas Hnath" - ); - assert_eq!( - event.link, - "https://www.agendalx.pt/events/event/nora-helmer/" - ); - assert_eq!(event.occurring_at.dates, "8 março a 11 maio"); - assert_eq!( - event.occurring_at.times, - "qua: 19h; qui: 19h; sex: 21h; sáb: 21h; dom: 16h" - ); - assert!(!event.is_for_children); - assert_eq!(event.venue, "Teatro Aberto"); - assert!(event.tags.is_empty()); - } - - #[test_log::test(tokio::test)] - async fn should_scrape_the_specified_event_with_an_italic_description_by_public_url() { - let event: Event = AgendaCulturalAPI::get_event_by_public_url( - "https://www.agendalx.pt/events/event/o-monte/", - ) - .await - .unwrap(); - - assert_eq!(event.title, "O Monte"); - assert!(event.details.description.starts_with("A partir de um texto de João Ascenso, O Monte é inspirado no relato da atriz Luísa Ortigoso sobre um ex-preso político que reencontra o seu torturador anos após a ditadura.")); - assert_eq!( - event.details.image_url, - "https://www.agendalx.pt/content/uploads/2025/03/omonte.jpg" - ); - assert_eq!(event.details.subtitle, "Teatro Livre"); - assert_eq!(event.link, "https://www.agendalx.pt/events/event/o-monte/"); - assert_eq!(event.occurring_at.dates, "24 abril a 4 maio"); - assert_eq!( - event.occurring_at.times, - "qua: 21h30; qui: 21h30; sex: 21h30; sáb: 18h; dom: 18h" - ); - assert_eq!(event.venue, "Teatro do Bairro"); - assert_eq!(event.tags.len(), 3); - assert!(!event.is_for_children); - assert_eq!( - event.tags, - ["Cucha Carvalheiro", "Miguel Sopas", "Teatro Livre"] - ); - } - - #[test_log::test(tokio::test)] - async fn should_scrape_and_classify_a_children_piece_as_such() { - let event: Event = AgendaCulturalAPI::get_event_by_public_url( - "https://www.agendalx.pt/events/event/um-sapato-especial/", - ) - .await - .unwrap(); - - assert_eq!(event.title, "Um sapato especial"); - assert!(event.details.description.starts_with("O Ursinho José gosta muito de ir brincar para o jardim. Joga à bola, às corridas, anda de bicicleta e nos baloiços. E ele é o campeão dos saltos! Mas um dia acontece algo inesperado e começa um")); - assert_eq!( - event.details.image_url, - "https://www.agendalx.pt/content/uploads/2018/09/T-SE-cartaz1.jpg" - ); - assert_eq!(event.details.subtitle, ""); - assert_eq!( - event.link, - "https://www.agendalx.pt/events/event/um-sapato-especial/" - ); - assert_eq!(event.occurring_at.dates, "14 junho"); - assert_eq!(event.occurring_at.times, "16h00"); - assert_eq!(event.venue, "Fábrica Braço de Prata"); - assert_eq!(event.tags.len(), 2); - assert_eq!(event.tags, ["crianças", "famílias"]); - assert!(event.is_for_children); - } } From 5eccc1c682188fe34f105d3af9e13b9880315f6e Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:54:19 +0100 Subject: [PATCH 4/6] chore: fmt --- AGENTS.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 72 +--------------------------------------------- src/api.rs | 2 +- src/discord/api.rs | 2 +- src/metrics.rs | 10 +++++-- 5 files changed, 82 insertions(+), 75 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1541cb1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +cargo build # build +cargo test # run all tests +cargo test # run single test +make check # cargo fmt + clippy -D warnings +make fmt # cargo fmt + clippy --fix +``` + +Tests use `test-log` and `tokio::test`. Integration tests in `tests/` require live Discord/API access; unit tests in `src/` use local fixtures from `res/tests/`. + +## Architecture + +One-shot async Rust binary (tokio). On each run it: + +1. Scrapes new theater/arts events from the `agendalx.pt` REST API + HTML +2. Posts new events as Discord embeds into per-month threads in two channels (Teatro, Artes) +3. Processes reactions on existing posts: updates "save for later" pins and sends vote DMs +4. Backs up vote data to `vote_backups/YYYY_MM_DD.json` + +### Module map + +| Module | Purpose | +|---|---| +| `src/agenda_cultural/` | Fetch events from agendalx.pt; parse HTML descriptions; map to `Event` model | +| `src/discord/api.rs` | `DiscordAPI` wrapper around serenity: send embeds, manage threads, reactions, DMs | +| `src/discord/backup.rs` | Reads bot DMs to reconstruct `VoteRecord` structs and write backup JSON | +| `src/config/` | Load all config from env vars at startup | +| `src/api.rs` | Orchestration: filter unsent events, map events to month-threads | +| `src/main.rs` | Entry point: runs Teatro + Artes pipelines, then triggers vote backup | +| `src/tracing.rs` | Sets up stdout + optional Grafana Loki logging | + +### Key data flow + +`AgendaCulturalAPI::get_events_by_month` → `filter_new_events_by_thread` (dedupes against already-sent Discord embeds) → `send_new_events` (creates/reuses month threads, posts embeds, adds reaction emojis) + +### Discord thread structure + +Each channel (Teatro / Artes) has public threads named `" "` (e.g. `"Maio 2026"`). Each event is a single embed message inside the relevant month thread. + +## Required Environment Variables + +| Variable | Format | Notes | +|---|---|---| +| `DISCORD_TOKEN` | string | Bot token | +| `DISCORD_TEATRO_CHANNEL_ID` | integer | Channel ID | +| `DISCORD_ARTES_CHANNEL_ID` | integer | Channel ID | +| `VOTING_EMOJIS` | `name:ID;name:ID;...` | Exactly 5, worst→best | +| `VENUE_TICKET_SHOP_URLS` | `venue:url;venue:url;...` | Semicolon-separated | +| `TICKET_SHOP_ICON_URL` | URL | Icon shown on ticket link | +| `LOKI_URL` | `https://user:token@host` | Optional; skipped if missing or unreachable | +| `OTLP_ENDPOINT` | `http://host:4317` | Optional; gRPC OTLP endpoint (Tempo) | +| `GATHER_NEW_EVENTS` | bool | Default `true`; set `false` to only process reactions | + +### Debug variables (all optional) + +| Variable | Default | Effect | +|---|---|---| +| `DEBUG_CLEAR_CHANNEL` | `false` | Delete all messages in both channels | +| `DEBUG_EXIT_AFTER_CLEARING` | `false` | Exit immediately after clearing | +| `DEBUG_SKIP_SENDING` | `false` | Fetch events but don't post them | +| `DEBUG_SKIP_FEATURE_REACTIONS` | `false` | Skip reaction processing | +| `DEBUG_SKIP_ARTES` | `false` | Only run Teatro pipeline | +| `DEBUG_EVENT_LIMIT` | unset | Limit events fetched per category | + +See `run-locally.sh` for a working local configuration example. diff --git a/CLAUDE.md b/CLAUDE.md index 1541cb1..47dc3e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,71 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -```bash -cargo build # build -cargo test # run all tests -cargo test # run single test -make check # cargo fmt + clippy -D warnings -make fmt # cargo fmt + clippy --fix -``` - -Tests use `test-log` and `tokio::test`. Integration tests in `tests/` require live Discord/API access; unit tests in `src/` use local fixtures from `res/tests/`. - -## Architecture - -One-shot async Rust binary (tokio). On each run it: - -1. Scrapes new theater/arts events from the `agendalx.pt` REST API + HTML -2. Posts new events as Discord embeds into per-month threads in two channels (Teatro, Artes) -3. Processes reactions on existing posts: updates "save for later" pins and sends vote DMs -4. Backs up vote data to `vote_backups/YYYY_MM_DD.json` - -### Module map - -| Module | Purpose | -|---|---| -| `src/agenda_cultural/` | Fetch events from agendalx.pt; parse HTML descriptions; map to `Event` model | -| `src/discord/api.rs` | `DiscordAPI` wrapper around serenity: send embeds, manage threads, reactions, DMs | -| `src/discord/backup.rs` | Reads bot DMs to reconstruct `VoteRecord` structs and write backup JSON | -| `src/config/` | Load all config from env vars at startup | -| `src/api.rs` | Orchestration: filter unsent events, map events to month-threads | -| `src/main.rs` | Entry point: runs Teatro + Artes pipelines, then triggers vote backup | -| `src/tracing.rs` | Sets up stdout + optional Grafana Loki logging | - -### Key data flow - -`AgendaCulturalAPI::get_events_by_month` → `filter_new_events_by_thread` (dedupes against already-sent Discord embeds) → `send_new_events` (creates/reuses month threads, posts embeds, adds reaction emojis) - -### Discord thread structure - -Each channel (Teatro / Artes) has public threads named `" "` (e.g. `"Maio 2026"`). Each event is a single embed message inside the relevant month thread. - -## Required Environment Variables - -| Variable | Format | Notes | -|---|---|---| -| `DISCORD_TOKEN` | string | Bot token | -| `DISCORD_TEATRO_CHANNEL_ID` | integer | Channel ID | -| `DISCORD_ARTES_CHANNEL_ID` | integer | Channel ID | -| `VOTING_EMOJIS` | `name:ID;name:ID;...` | Exactly 5, worst→best | -| `VENUE_TICKET_SHOP_URLS` | `venue:url;venue:url;...` | Semicolon-separated | -| `TICKET_SHOP_ICON_URL` | URL | Icon shown on ticket link | -| `LOKI_URL` | `https://user:token@host` | Optional; skipped if missing or unreachable | -| `OTLP_ENDPOINT` | `http://host:4317` | Optional; gRPC OTLP endpoint (Tempo) | -| `GATHER_NEW_EVENTS` | bool | Default `true`; set `false` to only process reactions | - -### Debug variables (all optional) - -| Variable | Default | Effect | -|---|---|---| -| `DEBUG_CLEAR_CHANNEL` | `false` | Delete all messages in both channels | -| `DEBUG_EXIT_AFTER_CLEARING` | `false` | Exit immediately after clearing | -| `DEBUG_SKIP_SENDING` | `false` | Fetch events but don't post them | -| `DEBUG_SKIP_FEATURE_REACTIONS` | `false` | Skip reaction processing | -| `DEBUG_SKIP_ARTES` | `false` | Only run Teatro pipeline | -| `DEBUG_EVENT_LIMIT` | unset | Limit events fetched per category | - -See `run-locally.sh` for a working local configuration example. +AGENTS.md \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 8e4438e..d317db3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,7 +4,7 @@ use crate::discord::api::{DiscordAPI, EventsThread}; use chrono::NaiveDate; use serenity::all::{ChannelId, GuildChannel, Message, PartialGuild}; use std::collections::BTreeMap; -use tracing::{debug, info, instrument, trace}; +use tracing::{debug, trace}; pub async fn filter_new_events_by_thread( discord: &DiscordAPI, diff --git a/src/discord/api.rs b/src/discord/api.rs index 3fa6161..faa2f89 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -20,7 +20,7 @@ use serenity::Client; use std::env; use std::fmt::Debug; use tracing::field::debug; -use tracing::{debug, error, info, instrument, trace, warn}; +use tracing::{debug, error, info, trace, warn}; const PORTUGUESE_MONTHS: [&str; 12] = [ "Janeiro", diff --git a/src/metrics.rs b/src/metrics.rs index 44f6723..edb77e3 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -192,11 +192,17 @@ pub fn record_vote_backup_duration(result: MetricResult, duration: Duration) { } pub fn record_pipeline_run_duration_without_event_gather(category: &Category, duration: Duration) { - PIPELINE_RUN_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into(), KeyValue::new("gathered_events", false)]); + PIPELINE_RUN_DURATION_SECONDS.record( + duration.as_secs_f64(), + &[category.into(), KeyValue::new("gathered_events", false)], + ); } pub fn record_pipeline_run_duration(category: &Category, duration: Duration) { - PIPELINE_RUN_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into(), KeyValue::new("gathered_events", true)]); + PIPELINE_RUN_DURATION_SECONDS.record( + duration.as_secs_f64(), + &[category.into(), KeyValue::new("gathered_events", true)], + ); } pub fn record_pipeline_error(stage: PipelineStage, error_kind: PipelineErrorKind) { From 6f7a08cdf42da24b22d423e15c315bb4f3669896 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:59:03 +0100 Subject: [PATCH 5/6] feat: add metric for get_events_by_month duration tracking --- src/main.rs | 9 ++++++--- src/metrics.rs | 9 +++++++++ tests/metrics_label_tests.rs | 10 +++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index f635663..e4e49ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,10 @@ use alertaemcena::discord::api::{DiscordAPI, EventsThread}; use alertaemcena::discord::backup::{backup_user_votes, VoteRecord}; use alertaemcena::metrics::{ record_event_send_duration, record_event_sent, record_events_fetched, record_pipeline_error, - record_pipeline_run_duration, record_pipeline_run_duration_without_event_gather, - record_reaction_processing_duration, record_vote_backup_duration, record_vote_backup_records, - set_threads_active, MetricResult, PipelineErrorKind, PipelineStage, + record_get_events_by_month_duration, record_pipeline_run_duration, + record_pipeline_run_duration_without_event_gather, record_reaction_processing_duration, + record_vote_backup_duration, record_vote_backup_records, set_threads_active, MetricResult, + PipelineErrorKind, PipelineStage, }; use alertaemcena::tracing::setup_tracing; use chrono::Utc; @@ -119,8 +120,10 @@ async fn run( return users_with_reactions; } + let get_events_started_at = Instant::now(); let events = AgendaCulturalAPI::get_events_by_month(&category, config.debug_config.event_limit).await; + record_get_events_by_month_duration(&category, get_events_started_at.elapsed()); if let Err(err) = events { error!("Failed getting events. Reason: {:?}", err); diff --git a/src/metrics.rs b/src/metrics.rs index edb77e3..2d96d96 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -135,6 +135,11 @@ lazy_static! { .with_description("Duration of reaction processing phase") .with_unit("s") .init(); + static ref GET_EVENTS_BY_MONTH_DURATION_SECONDS: Histogram = METER + .f64_histogram("aec_get_events_by_month_duration_seconds") + .with_description("Duration of AgendaCulturalAPI::get_events_by_month call") + .with_unit("s") + .init(); static ref DM_REVIEW_SENT_TOTAL: Counter = METER .u64_counter("aec_dm_review_sent_total") .with_description("Total DM review send attempts") @@ -179,6 +184,10 @@ pub fn record_reaction_processing_duration(category: &Category, duration: Durati REACTION_PROCESSING_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into()]); } +pub fn record_get_events_by_month_duration(category: &Category, duration: Duration) { + GET_EVENTS_BY_MONTH_DURATION_SECONDS.record(duration.as_secs_f64(), &[category.into()]); +} + pub fn record_dm_review_sent(result: MetricResult) { DM_REVIEW_SENT_TOTAL.add(1, &[result.into()]); } diff --git a/tests/metrics_label_tests.rs b/tests/metrics_label_tests.rs index 124eace..0f5de3d 100644 --- a/tests/metrics_label_tests.rs +++ b/tests/metrics_label_tests.rs @@ -1,6 +1,9 @@ use alertaemcena::agenda_cultural::model::Category; -use alertaemcena::metrics::{MetricResult, PipelineErrorKind, PipelineStage}; +use alertaemcena::metrics::{ + record_get_events_by_month_duration, MetricResult, PipelineErrorKind, PipelineStage, +}; use opentelemetry::KeyValue; +use std::time::Duration; #[test] fn should_convert_metric_enums_to_labels_using_to_string() { @@ -33,3 +36,8 @@ fn should_convert_metric_dimensions_into_key_value() { assert_eq!(error_kv.key.as_str(), "error_kind"); assert_eq!(error_kv.value.to_string(), "serialize"); } + +#[test] +fn should_record_get_events_by_month_duration_metric() { + record_get_events_by_month_duration(&Category::Teatro, Duration::from_millis(250)); +} From cf4ee1c361f8bea02c06005c19c323c09911087f Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:04:18 +0100 Subject: [PATCH 6/6] feat: add span for filter new events --- src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index e4e49ce..beb96a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use alertaemcena::config::model::{Config, EmojiConfig}; use alertaemcena::discord::api::{DiscordAPI, EventsThread}; use alertaemcena::discord::backup::{backup_user_votes, VoteRecord}; use alertaemcena::metrics::{ - record_event_send_duration, record_event_sent, record_events_fetched, record_pipeline_error, - record_get_events_by_month_duration, record_pipeline_run_duration, + record_event_send_duration, record_event_sent, record_events_fetched, + record_get_events_by_month_duration, record_pipeline_error, record_pipeline_run_duration, record_pipeline_run_duration_without_event_gather, record_reaction_processing_duration, record_vote_backup_duration, record_vote_backup_records, set_threads_active, MetricResult, PipelineErrorKind, PipelineStage, @@ -145,7 +145,9 @@ async fn run( let fetched_count: usize = events.values().map(|events| events.len()).sum(); record_events_fetched(&category, fetched_count as u64); - let new_events = filter_new_events_by_thread(discord, &guild, events, channel_id).await; + let new_events = filter_new_events_by_thread(discord, &guild, events, channel_id) + .instrument(info_span!("filter_new_events")) + .await; info!("Filtered new events");