Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
telemetry.c
Go to the documentation of this file.
1/*
2 This file is part of Ansel,
3 Copyright (C) 2026 Aurélien PIERRE.
4
5 Ansel is free software: you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 Ansel is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with Ansel. If not, see <http://www.gnu.org/licenses/>.
17*/
18
19#ifdef HAVE_CONFIG_H
20#include "config.h"
21#endif
22
23#include "common/telemetry.h"
24#include "common/darktable.h"
25
26#ifdef HAVE_TELEMETRY
27
28#include "common/image.h"
29#include "common/opencl.h"
30#include "control/conf.h"
31#include "gui/gtk.h"
32
33#include <curl/curl.h>
34#include <string.h>
35
36#define POSTHOG_API_KEY "phc_uLtshRLGnot4cMieYFebh4gxkszztKLcfHgEYSZF3Cu6"
37
38#ifndef POSTHOG_HOST
39#define POSTHOG_HOST "https://eu.i.posthog.com"
40#endif
41
42// "telemetry/enabled" is a confgen key (shown in Preferences). The other two are
43// intentionally NOT confgen so dt_conf_key_exists() reflects real user state.
44#define DT_TELEMETRY_ENABLED_KEY "telemetry/enabled"
45#define DT_TELEMETRY_ASKED_KEY "telemetry/consent_asked"
46#define DT_TELEMETRY_INSTALL_ID_KEY "telemetry/install_id"
47
48static gboolean _running = FALSE;
49static GThread *_worker = NULL;
50static GAsyncQueue *_queue = NULL; // queue of malloc'd JSON body strings
51static char *_distinct_id = NULL; // anonymous per-installation id
52static char _stop_sentinel; // queue marker meaning "stop"
53
54// Per-session aggregation, sent once in "session_end" at shutdown. Touched from
55// the GUI thread (module usage) and pipeline worker threads (file types), so all
56// access is guarded by _stats_lock.
57static GMutex _stats_lock;
58static GHashTable *_module_usage = NULL; // "category/name" -> count (GINT)
59static GHashTable *_file_types = NULL; // "ext" -> count (GINT)
60static int _raw_images = 0; // distinct images that were raw
61static int _nonraw_images = 0; // distinct images that were not raw
62static int _mosaiced_images = 0; // distinct images still needing demosaic
63static int _processed_images = 0; // distinct image+pipeline combinations
64// Dedup state, mirroring the crash-context dedup so a reprocessed image counts once.
65static int32_t _last_imgid = -1;
66static char _last_pipeline[32] = { 0 };
67
68// Discard HTTP response bodies; we only care that the POST went out.
69static size_t _discard_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
70{
71 return size * nmemb;
72}
73
74// Background sender: pops serialized JSON bodies and POSTs them to PostHog.
75static gpointer _telemetry_worker(gpointer data)
76{
77 CURL *curl = curl_easy_init();
78 struct curl_slist *headers = curl_slist_append(NULL, "Content-Type: application/json");
79 char url[512];
80 snprintf(url, sizeof(url), "%s/capture/", POSTHOG_HOST);
81
82 while(TRUE)
83 {
84 char *body = (char *)g_async_queue_pop(_queue); // blocks
85 if(body == &_stop_sentinel) break;
86
87 if(curl)
88 {
89 curl_easy_setopt(curl, CURLOPT_URL, url);
90 curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
91 curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
92 curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
93 curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
94 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _discard_cb);
95 curl_easy_perform(curl); // best-effort: ignore network errors
96 }
97 g_free(body);
98 }
99
100 if(headers) curl_slist_free_all(headers);
101 if(curl) curl_easy_cleanup(curl);
102 return NULL;
103}
104
105void dt_telemetry_capture(const char *event, JsonObject *properties)
106{
107 if(!_running || !event)
108 {
109 if(properties) json_object_unref(properties);
110 return;
111 }
112
113 JsonObject *root = json_object_new();
114 json_object_set_string_member(root, "api_key", POSTHOG_API_KEY);
115 json_object_set_string_member(root, "event", event);
116 json_object_set_string_member(root, "distinct_id", _distinct_id ? _distinct_id : "unknown");
117
118 GDateTime *now = g_date_time_new_now_utc();
119 gchar *ts = g_date_time_format_iso8601(now);
120 if(ts) json_object_set_string_member(root, "timestamp", ts);
121 g_free(ts);
122 g_date_time_unref(now);
123
124 // set_object_member takes ownership of the properties object.
125 json_object_set_object_member(root, "properties", properties ? properties : json_object_new());
126
127 JsonNode *node = json_node_new(JSON_NODE_OBJECT);
128 json_node_take_object(node, root);
129 JsonGenerator *gen = json_generator_new();
130 json_generator_set_root(gen, node);
131 gchar *body = json_generator_to_data(gen, NULL);
132 g_object_unref(gen);
133 json_node_free(node); // frees root and, transitively, properties
134
135 if(body) g_async_queue_push(_queue, body);
136}
137
138void dt_telemetry_record_module_usage(const char *category, const char *name)
139{
140 if(!_running || !category || !name || !*name) return;
141
142 char *key = g_strdup_printf("%s/%s", category, name);
143
144 g_mutex_lock(&_stats_lock);
145 if(!_module_usage)
146 _module_usage = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
147 const int count = GPOINTER_TO_INT(g_hash_table_lookup(_module_usage, key)) + 1;
148 // insert frees the duplicate key when the entry already exists.
149 g_hash_table_insert(_module_usage, key, GINT_TO_POINTER(count));
150 const gboolean first_use = (count == 1);
151 g_mutex_unlock(&_stats_lock);
152
153 // Send a discrete event the first time each module is used this session. Unlike
154 // the session_end aggregate (only sent on a clean exit), this reaches PostHog
155 // immediately, so usage is still recorded if the session later crashes. One
156 // event per distinct module per session keeps the volume low; PostHog can then
157 // count/break-down "module_used" by category and name.
158 if(first_use)
159 {
160 JsonObject *props = json_object_new();
161 json_object_set_string_member(props, "category", category);
162 json_object_set_string_member(props, "name", name);
163 dt_telemetry_capture("module_used", props);
164 }
165}
166
167void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
168{
169 if(!_running || !img) return;
170 const char *pl = pipeline ? pipeline : "";
171
172 g_mutex_lock(&_stats_lock);
173 // Count each image+pipeline once, even though pipelines reprocess constantly.
174 if(img->id == _last_imgid && !strcmp(pl, _last_pipeline))
175 {
176 g_mutex_unlock(&_stats_lock);
177 return;
178 }
179 _last_imgid = img->id;
180 g_strlcpy(_last_pipeline, pl, sizeof(_last_pipeline));
181
182 // Extension only - never the file name or path.
183 const char *dot = strrchr(img->filename, '.');
184 char *ext = g_ascii_strdown(dot ? dot + 1 : "none", -1);
185
186 if(!_file_types)
187 _file_types = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
188 const int count = GPOINTER_TO_INT(g_hash_table_lookup(_file_types, ext)) + 1;
189 // g_hash_table_insert takes ownership of ext (frees the dup key on replace).
190 g_hash_table_insert(_file_types, ext, GINT_TO_POINTER(count));
191 const gboolean first_ext = (count == 1);
192
193 const gboolean is_raw = dt_image_is_raw(img);
194 const gboolean is_ldr = dt_image_is_ldr(img);
195 const gboolean is_hdr = dt_image_is_hdr(img);
196 const gboolean is_mono = dt_image_is_monochrome(img);
197 const gboolean needs_demosaic = (img->dsc.filters != 0);
198
199 if(is_raw) _raw_images++; else _nonraw_images++;
200 if(needs_demosaic) _mosaiced_images++;
201 _processed_images++;
202 g_mutex_unlock(&_stats_lock);
203
204 // First time we see a given extension this session, send a discrete event so
205 // the kind of files processed reaches PostHog even if the session later
206 // crashes. "ext" is owned by the hash table now, so re-derive it for the event.
207 if(first_ext)
208 {
209 gchar *ext_lc = g_ascii_strdown(dot ? dot + 1 : "none", -1);
210 JsonObject *props = json_object_new();
211 json_object_set_string_member(props, "extension", ext_lc);
212 g_free(ext_lc);
213 json_object_set_boolean_member(props, "raw", is_raw);
214 json_object_set_boolean_member(props, "ldr", is_ldr);
215 json_object_set_boolean_member(props, "hdr", is_hdr);
216 json_object_set_boolean_member(props, "monochrome", is_mono);
217 json_object_set_boolean_member(props, "needs_demosaic", needs_demosaic);
218 json_object_set_string_member(props, "pipeline", pl);
219 dt_telemetry_capture("file_opened", props);
220 }
221}
222
223// Flatten a "name -> count" hashtable into top-level numeric properties named
224// "<prefix><sanitized-name>". PostHog only lets you filter/break-down/aggregate
225// on top-level scalar properties: nested objects are ingested but invisible in
226// insights, so module usage and file types must be flat to be usable in reports.
227// Caller must hold _stats_lock.
228static void _flatten_counts(JsonObject *p, const char *prefix, GHashTable *table)
229{
230 if(!table) return;
231
232 GHashTableIter it;
233 gpointer k, v;
234 g_hash_table_iter_init(&it, table);
235 while(g_hash_table_iter_next(&it, &k, &v))
236 {
237 // Build a PostHog-safe property name: prefix + key with every character
238 // outside [A-Za-z0-9_] replaced by '_' (e.g. "view/lighttable" -> "lighttable",
239 // prefixed -> "mod_view_lighttable").
240 gchar *safe = g_strdup_printf("%s%s", prefix, (const char *)k);
241 for(char *c = safe; *c; c++)
242 if(!g_ascii_isalnum(*c) && *c != '_') *c = '_';
243 json_object_set_int_member(p, safe, GPOINTER_TO_INT(v));
244 g_free(safe);
245 }
246}
247
248// Build the "session_end" payload: session length plus the per-session usage
249// aggregates. System properties are carried by "session_start", so we keep this
250// focused on what happened during the session.
251static JsonObject *_telemetry_session_end_properties(void)
252{
253 JsonObject *p = json_object_new();
254
255 const double dur = dt_get_wtime() - darktable.start_wtime;
256 json_object_set_double_member(p, "session_seconds", (dur > 0.0) ? dur : 0.0);
257
258 g_mutex_lock(&_stats_lock);
259 // Flat numeric properties so they show up and can be aggregated in PostHog:
260 // mod_view_<name>, mod_lib_<plugin>, mod_iop_<op>, ext_<extension>.
261 _flatten_counts(p, "mod_", _module_usage);
262 _flatten_counts(p, "ext_", _file_types);
263 json_object_set_int_member(p, "images_processed", _processed_images);
264 json_object_set_int_member(p, "raw_images", _raw_images);
265 json_object_set_int_member(p, "nonraw_images", _nonraw_images);
266 json_object_set_int_member(p, "mosaiced_images", _mosaiced_images);
267 g_mutex_unlock(&_stats_lock);
268
269 return p;
270}
271
272// Build the common "what machine is this" properties shared by analytics events.
273static JsonObject *_telemetry_system_properties(void)
274{
275 JsonObject *p = json_object_new();
276
277 // Explicitly forbid capturing IP and GeoIP
278 /*
279 "$geoip_disable": true,
280 "$ip": "0.0.0.0"
281 */
282 json_object_set_boolean_member(p, "$geoip_disable", TRUE);
283 json_object_set_string_member(p, "$ip", "0.0.0.0");
284
285 json_object_set_string_member(p, "app_version", darktable_package_version);
286 json_object_set_string_member(p, "build_type", DT_BUILD_TYPE);
287
288 gchar *os = g_get_os_info(G_OS_INFO_KEY_PRETTY_NAME);
289 if(os)
290 {
291 json_object_set_string_member(p, "os", os);
292 g_free(os);
293 }
294
295 json_object_set_int_member(p, "cpu_cores", g_get_num_processors());
297 json_object_set_double_member(p, "ram_gb",
298 (double)darktable.dtresources.total_memory / (1024.0 * 1024.0 * 1024.0));
299
300 const gboolean cl = dt_opencl_is_enabled();
301 json_object_set_boolean_member(p, "opencl", cl);
302#ifdef HAVE_OPENCL
303 // Device enumeration fields (num_devs/dev) only exist in HAVE_OPENCL builds.
305 && darktable.opencl->dev[0].name)
306 json_object_set_string_member(p, "gpu", darktable.opencl->dev[0].name);
307#endif
308
309#if !defined(_WIN32) && !defined(__APPLE__)
310 const char *session_type = g_getenv("XDG_SESSION_TYPE");
311 if(session_type && *session_type) json_object_set_string_member(p, "display_server", session_type);
312 const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
313 if(desktop && *desktop) json_object_set_string_member(p, "desktop_environment", desktop);
314#endif
315
316 if(darktable.gui)
317 {
318 json_object_set_double_member(p, "dpi", darktable.gui->dpi);
319 json_object_set_double_member(p, "ppd", darktable.gui->ppd);
320 GdkDisplay *display = gdk_display_get_default();
321 GdkMonitor *mon = display ? gdk_display_get_primary_monitor(display) : NULL;
322 if(!mon && display && gdk_display_get_n_monitors(display) > 0) mon = gdk_display_get_monitor(display, 0);
323 if(mon)
324 {
325 GdkRectangle geo;
326 gdk_monitor_get_geometry(mon, &geo);
327 json_object_set_int_member(p, "screen_width", geo.width);
328 json_object_set_int_member(p, "screen_height", geo.height);
329 }
330 }
331
332 return p;
333}
334
335void dt_telemetry_init(const gboolean have_gui)
336{
337 // Consent is gathered once at startup by dt_privacy_ask_consent() (a single
338 // dialog shared with crash reporting). Here we only honor the resulting toggle.
339 if(!dt_conf_get_bool(DT_TELEMETRY_ENABLED_KEY)) return;
340
341 if(POSTHOG_API_KEY[0] == '\0')
342 {
343 dt_print(DT_DEBUG_CONTROL, "[telemetry] no PostHog API key configured, analytics disabled\n");
344 return;
345 }
346
347 // Anonymous, stable-per-installation id.
348 gchar *id = dt_conf_get_string(DT_TELEMETRY_INSTALL_ID_KEY);
349 if(!id || !*id)
350 {
351 g_free(id);
352 id = g_uuid_string_random();
353 dt_conf_set_string(DT_TELEMETRY_INSTALL_ID_KEY, id);
354 }
355 _distinct_id = id; // kept; freed at shutdown
356
357 _queue = g_async_queue_new();
358 _worker = g_thread_new("telemetry", _telemetry_worker, NULL);
359 _running = TRUE;
360
361 dt_print(DT_DEBUG_CONTROL, "[telemetry] usage analytics initialized\n");
362
363 // One event per launch carries the system info, so every session (healthy or
364 // not) is represented for population stats.
365 dt_telemetry_capture("session_start", _telemetry_system_properties());
366}
367
368void dt_telemetry_shutdown(void)
369{
370 if(!_running) return;
371
372 // Emit the per-session usage summary while we are still running (capture is a
373 // no-op once _running is cleared), then stop accepting new events.
374 dt_telemetry_capture("session_end", _telemetry_session_end_properties());
375
376 _running = FALSE;
377
378 // Tell the worker to drain and stop, then wait for the in-flight POST.
379 g_async_queue_push(_queue, &_stop_sentinel);
380 if(_worker)
381 {
382 g_thread_join(_worker);
383 _worker = NULL;
384 }
385 if(_queue)
386 {
387 g_async_queue_unref(_queue);
388 _queue = NULL;
389 }
390 g_free(_distinct_id);
391 _distinct_id = NULL;
392
393 g_mutex_lock(&_stats_lock);
394 if(_module_usage)
395 {
396 g_hash_table_destroy(_module_usage);
397 _module_usage = NULL;
398 }
399 if(_file_types)
400 {
401 g_hash_table_destroy(_file_types);
402 _file_types = NULL;
403 }
404 g_mutex_unlock(&_stats_lock);
405}
406
407#else // !HAVE_TELEMETRY
408
409void dt_telemetry_init(const gboolean have_gui)
410{
411}
412
414{
415}
416
417void dt_telemetry_capture(const char *event, JsonObject *properties)
418{
419 if(properties) json_object_unref(properties);
420}
421
422void dt_telemetry_record_module_usage(const char *category, const char *name)
423{
424}
425
426void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
427{
428}
429
430#endif // HAVE_TELEMETRY
431
432// clang-format off
433// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
434// vim: shiftwidth=2 expandtab tabstop=2 cindent
435// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
436// clang-format on
#define TRUE
Definition ashift_lsd.c:162
#define FALSE
Definition ashift_lsd.c:158
static const dt_aligned_pixel_simd_t const dt_adaptation_t const float p
gboolean dt_image_is_raw(const dt_image_t *img)
gboolean dt_image_is_hdr(const dt_image_t *img)
gboolean dt_image_is_monochrome(const dt_image_t *img)
gboolean dt_image_is_ldr(const dt_image_t *img)
char * key
char * name
const char darktable_package_version[]
#define DT_BUILD_TYPE
int dt_conf_get_bool(const char *name)
gchar * dt_conf_get_string(const char *name)
void dt_conf_set_string(const char *name, const char *val)
darktable_t darktable
Definition darktable.c:181
void dt_print(dt_debug_thread_t thread, const char *msg,...)
Definition darktable.c:1542
@ DT_DEBUG_CONTROL
Definition darktable.h:716
static double dt_get_wtime(void)
Definition darktable.h:914
const float v
float *const restrict const size_t k
size_t size
Definition mipmap_cache.c:3
int dt_opencl_is_enabled(void)
Definition opencl.c:2737
static const char *const mon[12]
Definition strptime.c:99
struct dt_gui_gtk_t * gui
Definition darktable.h:775
struct dt_sys_resources_t dtresources
Definition darktable.h:834
struct dt_opencl_t * opencl
Definition darktable.h:785
double start_wtime
Definition darktable.h:828
double dpi
Definition gtk.h:200
double ppd
Definition gtk.h:200
dt_iop_buffer_dsc_t dsc
Definition image.h:337
char filename[DT_MAX_FILENAME_LEN]
Definition image.h:304
int32_t id
Definition image.h:319
uint32_t filters
Definition format.h:60
const char * name
Definition opencl.h:149
int num_devs
Definition opencl.h:236
dt_opencl_device_t * dev
Definition opencl.h:246
int inited
Definition opencl.h:232
void dt_telemetry_record_module_usage(const char *category, const char *name)
Definition telemetry.c:422
void dt_telemetry_shutdown(void)
Definition telemetry.c:413
void dt_telemetry_capture(const char *event, JsonObject *properties)
Definition telemetry.c:417
void dt_telemetry_init(const gboolean have_gui)
Definition telemetry.c:409
void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
Definition telemetry.c:426