Skip to main content

mas_handlers/activity_tracker/
mod.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8mod bound;
9mod worker;
10
11use std::net::IpAddr;
12
13use chrono::{DateTime, Utc};
14use mas_context::LogContext;
15use mas_data_model::{
16    BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession,
17};
18use mas_storage::BoxRepositoryFactory;
19use tokio_util::{sync::CancellationToken, task::TaskTracker};
20use ulid::Ulid;
21
22pub use self::bound::Bound;
23use self::worker::Worker;
24
25static MESSAGE_QUEUE_SIZE: usize = 1000;
26
27#[derive(Clone, Copy, Debug, PartialOrd, PartialEq, Eq, Hash)]
28enum SessionKind {
29    OAuth2,
30    Compat,
31    /// Session associated with personal access tokens
32    Personal,
33    Browser,
34}
35
36impl SessionKind {
37    const fn as_str(self) -> &'static str {
38        match self {
39            SessionKind::OAuth2 => "oauth2",
40            SessionKind::Compat => "compat",
41            SessionKind::Personal => "personal",
42            SessionKind::Browser => "browser",
43        }
44    }
45}
46
47enum Message {
48    Record {
49        kind: SessionKind,
50        id: Ulid,
51        date_time: DateTime<Utc>,
52        ip: Option<IpAddr>,
53    },
54    Flush(tokio::sync::oneshot::Sender<()>),
55}
56
57#[derive(Clone)]
58pub struct ActivityTracker {
59    channel: tokio::sync::mpsc::Sender<Message>,
60}
61
62impl ActivityTracker {
63    /// Create a new activity tracker
64    ///
65    /// It will spawn the background worker and a loop to flush the tracker on
66    /// the task tracker, and both will shut themselves down, flushing one last
67    /// time, when the cancellation token is cancelled.
68    #[must_use]
69    pub fn new(
70        repository_factory: BoxRepositoryFactory,
71        flush_interval: std::time::Duration,
72        task_tracker: &TaskTracker,
73        cancellation_token: CancellationToken,
74    ) -> Self {
75        let worker = Worker::new(repository_factory);
76        let (sender, receiver) = tokio::sync::mpsc::channel(MESSAGE_QUEUE_SIZE);
77        let tracker = ActivityTracker { channel: sender };
78
79        // Spawn the flush loop and the worker
80        task_tracker.spawn(LogContext::new("activity-tracker-flush").run(|| {
81            tracker
82                .clone()
83                .flush_loop(flush_interval, cancellation_token.clone())
84        }));
85        task_tracker.spawn(
86            LogContext::new("activity-tracker").run(|| worker.run(receiver, cancellation_token)),
87        );
88
89        tracker
90    }
91
92    /// Bind the activity tracker to an IP address.
93    #[must_use]
94    pub fn bind(self, ip: Option<IpAddr>) -> Bound {
95        Bound::new(self, ip)
96    }
97
98    /// Record activity in an OAuth 2.0 session.
99    pub async fn record_oauth2_session(
100        &self,
101        clock: &dyn Clock,
102        session: &Session,
103        ip: Option<IpAddr>,
104    ) {
105        let res = self
106            .channel
107            .send(Message::Record {
108                kind: SessionKind::OAuth2,
109                id: session.id,
110                date_time: clock.now(),
111                ip,
112            })
113            .await;
114
115        if let Err(e) = res {
116            tracing::error!("Failed to record OAuth2 session: {}", e);
117        }
118    }
119
120    /// Record activity in a personal session.
121    pub async fn record_personal_session(
122        &self,
123        clock: &dyn Clock,
124        session: &PersonalSession,
125        ip: Option<IpAddr>,
126    ) {
127        let res = self
128            .channel
129            .send(Message::Record {
130                kind: SessionKind::Personal,
131                id: session.id,
132                date_time: clock.now(),
133                ip,
134            })
135            .await;
136
137        if let Err(e) = res {
138            tracing::error!("Failed to record Personal session: {}", e);
139        }
140    }
141
142    /// Record activity in a compat session.
143    pub async fn record_compat_session(
144        &self,
145        clock: &dyn Clock,
146        compat_session: &CompatSession,
147        ip: Option<IpAddr>,
148    ) {
149        let res = self
150            .channel
151            .send(Message::Record {
152                kind: SessionKind::Compat,
153                id: compat_session.id,
154                date_time: clock.now(),
155                ip,
156            })
157            .await;
158
159        if let Err(e) = res {
160            tracing::error!("Failed to record compat session: {}", e);
161        }
162    }
163
164    /// Record activity in a browser session.
165    pub async fn record_browser_session(
166        &self,
167        clock: &dyn Clock,
168        browser_session: &BrowserSession,
169        ip: Option<IpAddr>,
170    ) {
171        let res = self
172            .channel
173            .send(Message::Record {
174                kind: SessionKind::Browser,
175                id: browser_session.id,
176                date_time: clock.now(),
177                ip,
178            })
179            .await;
180
181        if let Err(e) = res {
182            tracing::error!("Failed to record browser session: {}", e);
183        }
184    }
185
186    /// Manually flush the activity tracker.
187    pub async fn flush(&self) {
188        let (tx, rx) = tokio::sync::oneshot::channel();
189        let res = self.channel.send(Message::Flush(tx)).await;
190
191        match res {
192            Ok(()) => {
193                if let Err(e) = rx.await {
194                    tracing::error!(
195                        error = &e as &dyn std::error::Error,
196                        "Failed to flush activity tracker"
197                    );
198                }
199            }
200            Err(e) => {
201                tracing::error!(
202                    error = &e as &dyn std::error::Error,
203                    "Failed to flush activity tracker"
204                );
205            }
206        }
207    }
208
209    /// Regularly flush the activity tracker.
210    async fn flush_loop(
211        self,
212        interval: std::time::Duration,
213        cancellation_token: CancellationToken,
214    ) {
215        // This guard on the shutdown token is to ensure that if this task crashes for
216        // any reason, the server will shut down
217        let _guard = cancellation_token.clone().drop_guard();
218        let mut interval = tokio::time::interval(interval);
219        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
220
221        loop {
222            tokio::select! {
223                biased;
224
225                () = cancellation_token.cancelled() => {
226                    // The cancellation token was cancelled, so we should exit
227                    return;
228                }
229
230                // First check if the channel is closed, then check if the timer expired
231                () = self.channel.closed() => {
232                    // The channel was closed, so we should exit
233                    return;
234                }
235
236
237                _ = interval.tick() => {
238                    self.flush().await;
239                }
240            }
241        }
242    }
243}