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#ifdef __APPLE__
37#include <sys/sysctl.h>
38#endif
39
40#define POSTHOG_API_KEY "phc_uLtshRLGnot4cMieYFebh4gxkszztKLcfHgEYSZF3Cu6"
41
42#ifndef POSTHOG_HOST
43#define POSTHOG_HOST "https://eu.i.posthog.com"
44#endif
45
46// "telemetry/enabled" is a confgen key (shown in Preferences). The other two are
47// intentionally NOT confgen so dt_conf_key_exists() reflects real user state.
48#define DT_TELEMETRY_ENABLED_KEY "telemetry/enabled"
49#define DT_TELEMETRY_ASKED_KEY "telemetry/consent_asked"
50#define DT_TELEMETRY_INSTALL_ID_KEY "telemetry/install_id"
51
52static gboolean _running = FALSE;
53static GThread *_worker = NULL;
54static GAsyncQueue *_queue = NULL; // queue of malloc'd JSON body strings
55static char *_distinct_id = NULL; // anonymous per-installation id
56static char _stop_sentinel; // queue marker meaning "stop"
57
58// Per-session aggregation, sent once in "session_end" at shutdown. Touched from
59// the GUI thread (module usage) and pipeline worker threads (file types), so all
60// access is guarded by _stats_lock.
61static GMutex _stats_lock;
62static GHashTable *_module_usage = NULL; // "category/name" -> count (GINT)
63static GHashTable *_file_types = NULL; // "ext" -> count (GINT)
64static int _raw_images = 0; // distinct images that were raw
65static int _nonraw_images = 0; // distinct images that were not raw
66static int _mosaiced_images = 0; // distinct images still needing demosaic
67static int _processed_images = 0; // distinct image+pipeline combinations
68// Dedup state, mirroring the crash-context dedup so a reprocessed image counts once.
69static int32_t _last_imgid = -1;
70static char _last_pipeline[32] = { 0 };
71
72// Discard HTTP response bodies; we only care that the POST went out.
73static size_t _discard_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
74{
75 return size * nmemb;
76}
77
78// Background sender: pops serialized JSON bodies and POSTs them to PostHog.
79static gpointer _telemetry_worker(gpointer data)
80{
81 CURL *curl = curl_easy_init();
82 struct curl_slist *headers = curl_slist_append(NULL, "Content-Type: application/json");
83 char url[512];
84 snprintf(url, sizeof(url), "%s/capture/", POSTHOG_HOST);
85
86 while(TRUE)
87 {
88 char *body = (char *)g_async_queue_pop(_queue); // blocks
89 if(body == &_stop_sentinel) break;
90
91 if(curl)
92 {
93 curl_easy_setopt(curl, CURLOPT_URL, url);
94 curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
95 curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
96 curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
97 curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
98 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _discard_cb);
99#if defined(_WIN32) && defined(CURLSSLOPT_NATIVE_CA)
100 // On Windows the packaged libcurl has no usable CA bundle on disk, so TLS
101 // verification of the HTTPS endpoint fails and every POST is silently
102 // dropped (Sentry works because sentry-native uses WinHTTP). Verify against
103 // the Windows system certificate store instead. (libcurl >= 7.71)
104 curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA);
105#endif
106 const CURLcode res = curl_easy_perform(curl); // best-effort: ignore network errors
107 if(res != CURLE_OK)
108 dt_print(DT_DEBUG_CONTROL, "[telemetry] POST failed: %s\n", curl_easy_strerror(res));
109 }
110 g_free(body);
111 }
112
113 if(headers) curl_slist_free_all(headers);
114 if(curl) curl_easy_cleanup(curl);
115 return NULL;
116}
117
118void dt_telemetry_capture(const char *event, JsonObject *properties)
119{
120 if(!_running || !event)
121 {
122 if(properties) json_object_unref(properties);
123 return;
124 }
125
126 JsonObject *root = json_object_new();
127 json_object_set_string_member(root, "api_key", POSTHOG_API_KEY);
128 json_object_set_string_member(root, "event", event);
129 json_object_set_string_member(root, "distinct_id", _distinct_id ? _distinct_id : "unknown");
130
131 GDateTime *now = g_date_time_new_now_utc();
132 gchar *ts = g_date_time_format_iso8601(now);
133 if(ts) json_object_set_string_member(root, "timestamp", ts);
134 g_free(ts);
135 g_date_time_unref(now);
136
137 // set_object_member takes ownership of the properties object.
138 json_object_set_object_member(root, "properties", properties ? properties : json_object_new());
139
140 JsonNode *node = json_node_new(JSON_NODE_OBJECT);
141 json_node_take_object(node, root);
142 JsonGenerator *gen = json_generator_new();
143 json_generator_set_root(gen, node);
144 gchar *body = json_generator_to_data(gen, NULL);
145 g_object_unref(gen);
146 json_node_free(node); // frees root and, transitively, properties
147
148 if(body) g_async_queue_push(_queue, body);
149}
150
151void dt_telemetry_record_module_usage(const char *category, const char *name)
152{
153 if(!_running || !category || !name || !*name) return;
154
155 char *key = g_strdup_printf("%s/%s", category, name);
156
157 g_mutex_lock(&_stats_lock);
158 if(!_module_usage)
159 _module_usage = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
160 const int count = GPOINTER_TO_INT(g_hash_table_lookup(_module_usage, key)) + 1;
161 // insert frees the duplicate key when the entry already exists.
162 g_hash_table_insert(_module_usage, key, GINT_TO_POINTER(count));
163 const gboolean first_use = (count == 1);
164 g_mutex_unlock(&_stats_lock);
165
166 // Send a discrete event the first time each module is used this session. Unlike
167 // the session_end aggregate (only sent on a clean exit), this reaches PostHog
168 // immediately, so usage is still recorded if the session later crashes. One
169 // event per distinct module per session keeps the volume low; PostHog can then
170 // count/break-down "module_used" by category and name.
171 if(first_use)
172 {
173 JsonObject *props = json_object_new();
174 json_object_set_string_member(props, "category", category);
175 json_object_set_string_member(props, "name", name);
176 dt_telemetry_capture("module_used", props);
177 }
178}
179
180void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
181{
182 if(!_running || !img) return;
183 const char *pl = pipeline ? pipeline : "";
184
185 g_mutex_lock(&_stats_lock);
186 // Count each image+pipeline once, even though pipelines reprocess constantly.
187 if(img->id == _last_imgid && !strcmp(pl, _last_pipeline))
188 {
189 g_mutex_unlock(&_stats_lock);
190 return;
191 }
192 _last_imgid = img->id;
193 g_strlcpy(_last_pipeline, pl, sizeof(_last_pipeline));
194
195 // Extension only - never the file name or path.
196 const char *dot = strrchr(img->filename, '.');
197 char *ext = g_ascii_strdown(dot ? dot + 1 : "none", -1);
198
199 if(!_file_types)
200 _file_types = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
201 const int count = GPOINTER_TO_INT(g_hash_table_lookup(_file_types, ext)) + 1;
202 // g_hash_table_insert takes ownership of ext (frees the dup key on replace).
203 g_hash_table_insert(_file_types, ext, GINT_TO_POINTER(count));
204 const gboolean first_ext = (count == 1);
205
206 const gboolean is_raw = dt_image_is_raw(img);
207 const gboolean is_ldr = dt_image_is_ldr(img);
208 const gboolean is_hdr = dt_image_is_hdr(img);
209 const gboolean is_mono = dt_image_is_monochrome(img);
210 const gboolean needs_demosaic = (img->dsc.filters != 0);
211
212 if(is_raw) _raw_images++; else _nonraw_images++;
213 if(needs_demosaic) _mosaiced_images++;
214 _processed_images++;
215 g_mutex_unlock(&_stats_lock);
216
217 // First time we see a given extension this session, send a discrete event so
218 // the kind of files processed reaches PostHog even if the session later
219 // crashes. "ext" is owned by the hash table now, so re-derive it for the event.
220 if(first_ext)
221 {
222 gchar *ext_lc = g_ascii_strdown(dot ? dot + 1 : "none", -1);
223 JsonObject *props = json_object_new();
224 json_object_set_string_member(props, "extension", ext_lc);
225 g_free(ext_lc);
226 json_object_set_boolean_member(props, "raw", is_raw);
227 json_object_set_boolean_member(props, "ldr", is_ldr);
228 json_object_set_boolean_member(props, "hdr", is_hdr);
229 json_object_set_boolean_member(props, "monochrome", is_mono);
230 json_object_set_boolean_member(props, "needs_demosaic", needs_demosaic);
231 json_object_set_string_member(props, "pipeline", pl);
232 dt_telemetry_capture("file_opened", props);
233 }
234}
235
236// Flatten a "name -> count" hashtable into top-level numeric properties named
237// "<prefix><sanitized-name>". PostHog only lets you filter/break-down/aggregate
238// on top-level scalar properties: nested objects are ingested but invisible in
239// insights, so module usage and file types must be flat to be usable in reports.
240// Caller must hold _stats_lock.
241static void _flatten_counts(JsonObject *p, const char *prefix, GHashTable *table)
242{
243 if(!table) return;
244
245 GHashTableIter it;
246 gpointer k, v;
247 g_hash_table_iter_init(&it, table);
248 while(g_hash_table_iter_next(&it, &k, &v))
249 {
250 // Build a PostHog-safe property name: prefix + key with every character
251 // outside [A-Za-z0-9_] replaced by '_' (e.g. "view/lighttable" -> "lighttable",
252 // prefixed -> "mod_view_lighttable").
253 gchar *safe = g_strdup_printf("%s%s", prefix, (const char *)k);
254 for(char *c = safe; *c; c++)
255 if(!g_ascii_isalnum(*c) && *c != '_') *c = '_';
256 json_object_set_int_member(p, safe, GPOINTER_TO_INT(v));
257 g_free(safe);
258 }
259}
260
261// Build the "session_end" payload: session length plus the per-session usage
262// aggregates. System properties are carried by "session_start", so we keep this
263// focused on what happened during the session.
264static JsonObject *_telemetry_session_end_properties(void)
265{
266 JsonObject *p = json_object_new();
267
268 const double dur = dt_get_wtime() - darktable.start_wtime;
269 json_object_set_double_member(p, "session_seconds", (dur > 0.0) ? dur : 0.0);
270
271 // Stamp the release on session_end too (not only session_start), so average
272 // session length can be grouped by release and cross-checked against Sentry,
273 // keyed on the same full commit SHA.
274 json_object_set_string_member(p, "commit", darktable_commit_hash);
275 json_object_set_string_member(p, "app_version", darktable_package_version);
276 json_object_set_string_member(p, "build_channel", DT_BUILD_CHANNEL);
277 // Same per-run id as the Sentry session_id tag, to correlate without double count.
278 json_object_set_string_member(p, "session_id", dt_session_id());
279
280 g_mutex_lock(&_stats_lock);
281 // Flat numeric properties so they show up and can be aggregated in PostHog:
282 // mod_view_<name>, mod_lib_<plugin>, mod_iop_<op>, ext_<extension>.
283 _flatten_counts(p, "mod_", _module_usage);
284 _flatten_counts(p, "ext_", _file_types);
285 json_object_set_int_member(p, "images_processed", _processed_images);
286 json_object_set_int_member(p, "raw_images", _raw_images);
287 json_object_set_int_member(p, "nonraw_images", _nonraw_images);
288 json_object_set_int_member(p, "mosaiced_images", _mosaiced_images);
289 g_mutex_unlock(&_stats_lock);
290
291 return p;
292}
293
294// Build the common "what machine is this" properties shared by analytics events.
295static JsonObject *_telemetry_system_properties(void)
296{
297 JsonObject *p = json_object_new();
298
299 // Explicitly forbid capturing IP and GeoIP
300 /*
301 "$geoip_disable": true,
302 "$ip": "0.0.0.0"
303 */
304 json_object_set_boolean_member(p, "$geoip_disable", TRUE);
305 json_object_set_string_member(p, "$ip", "0.0.0.0");
306
307 json_object_set_string_member(p, "app_version", darktable_package_version);
308 // Full commit SHA: consistent release id across shallow/full clones (see sentry.c).
309 json_object_set_string_member(p, "commit", darktable_commit_hash);
310 // Same per-run id as the Sentry session_id tag, to correlate without double count.
311 json_object_set_string_member(p, "session_id", dt_session_id());
312 json_object_set_string_member(p, "build_type", DT_BUILD_TYPE);
313 // Full C compiler flags baked in at configure time (includes -DNDEBUG, -O3, -g, etc.)
314 json_object_set_string_member(p, "build_cflags", DT_BUILD_C_FLAGS);
315 // "nightly" for official builds, "self-build" otherwise - lets analytics exclude
316 // local/development builds from population stats.
317 json_object_set_string_member(p, "build_channel", DT_BUILD_CHANNEL);
318
319 gchar *os = g_get_os_info(G_OS_INFO_KEY_PRETTY_NAME);
320#ifdef __APPLE__
321 // macOS has no /etc/os-release, so g_get_os_info() returns NULL there. Build a
322 // pretty name from the product version (e.g. "macOS 15.1") via sysctl.
323 if(!os)
324 {
325 char ver[256] = { 0 };
326 size_t len = sizeof(ver);
327 if(sysctlbyname("kern.osproductversion", ver, &len, NULL, 0) == 0 && ver[0])
328 os = g_strdup_printf("macOS %s", ver);
329 else
330 os = g_strdup("macOS");
331 }
332#endif
333 if(os)
334 {
335 json_object_set_string_member(p, "os", os);
336 g_free(os);
337 }
338
339 json_object_set_int_member(p, "cpu_cores", g_get_num_processors());
341 json_object_set_double_member(p, "ram_gb",
342 (double)darktable.dtresources.total_memory / (1024.0 * 1024.0 * 1024.0));
343
344 const gboolean cl = dt_opencl_is_enabled();
345 json_object_set_boolean_member(p, "opencl", cl);
346#ifdef HAVE_OPENCL
347 // Device enumeration fields (num_devs/dev) only exist in HAVE_OPENCL builds.
349 && darktable.opencl->dev[0].name)
350 json_object_set_string_member(p, "gpu", darktable.opencl->dev[0].name);
351#endif
352
353#if !defined(_WIN32) && !defined(__APPLE__)
354 const char *session_type = g_getenv("XDG_SESSION_TYPE");
355 if(session_type && *session_type) json_object_set_string_member(p, "display_server", session_type);
356 const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
357 if(desktop && *desktop) json_object_set_string_member(p, "desktop_environment", desktop);
358#endif
359
360 if(darktable.gui)
361 {
362 json_object_set_double_member(p, "dpi", darktable.gui->dpi);
363 json_object_set_double_member(p, "ppd", darktable.gui->ppd);
364 GdkDisplay *display = gdk_display_get_default();
365 GdkMonitor *mon = display ? gdk_display_get_primary_monitor(display) : NULL;
366 if(!mon && display && gdk_display_get_n_monitors(display) > 0) mon = gdk_display_get_monitor(display, 0);
367 if(mon)
368 {
369 GdkRectangle geo;
370 gdk_monitor_get_geometry(mon, &geo);
371 json_object_set_int_member(p, "screen_width", geo.width);
372 json_object_set_int_member(p, "screen_height", geo.height);
373 }
374 }
375
376 return p;
377}
378
379void dt_telemetry_init(const gboolean have_gui)
380{
381 // Consent is gathered once at startup by dt_privacy_ask_consent() (a single
382 // dialog shared with crash reporting). Here we only honor the resulting toggle.
383 if(!dt_conf_get_bool(DT_TELEMETRY_ENABLED_KEY)) return;
384
385 if(POSTHOG_API_KEY[0] == '\0')
386 {
387 dt_print(DT_DEBUG_CONTROL, "[telemetry] no PostHog API key configured, analytics disabled\n");
388 return;
389 }
390
391 // Anonymous, stable-per-installation id, shared with Sentry (dt_install_id) so
392 // the same user de-duplicates across both systems.
393 _distinct_id = g_strdup(dt_install_id()); // kept; freed at shutdown
394
395 _queue = g_async_queue_new();
396 _worker = g_thread_new("telemetry", _telemetry_worker, NULL);
397 _running = TRUE;
398
399 dt_print(DT_DEBUG_CONTROL, "[telemetry] usage analytics initialized\n");
400
401 // One event per launch carries the system info, so every session (healthy or
402 // not) is represented for population stats.
403 dt_telemetry_capture("session_start", _telemetry_system_properties());
404}
405
406void dt_telemetry_shutdown(void)
407{
408 if(!_running) return;
409
410 // Emit the per-session usage summary while we are still running (capture is a
411 // no-op once _running is cleared), then stop accepting new events.
412 dt_telemetry_capture("session_end", _telemetry_session_end_properties());
413
414 _running = FALSE;
415
416 // Tell the worker to drain and stop, then wait for the in-flight POST.
417 g_async_queue_push(_queue, &_stop_sentinel);
418 if(_worker)
419 {
420 g_thread_join(_worker);
421 _worker = NULL;
422 }
423 if(_queue)
424 {
425 g_async_queue_unref(_queue);
426 _queue = NULL;
427 }
428 g_free(_distinct_id);
429 _distinct_id = NULL;
430
431 g_mutex_lock(&_stats_lock);
432 if(_module_usage)
433 {
434 g_hash_table_destroy(_module_usage);
435 _module_usage = NULL;
436 }
437 if(_file_types)
438 {
439 g_hash_table_destroy(_file_types);
440 _file_types = NULL;
441 }
442 g_mutex_unlock(&_stats_lock);
443}
444
445#else // !HAVE_TELEMETRY
446
447void dt_telemetry_init(const gboolean have_gui)
448{
449}
450
452{
453}
454
455void dt_telemetry_capture(const char *event, JsonObject *properties)
456{
457 if(properties) json_object_unref(properties);
458}
459
460void dt_telemetry_record_module_usage(const char *category, const char *name)
461{
462}
463
464void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
465{
466}
467
468#endif // HAVE_TELEMETRY
469
470// clang-format off
471// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
472// vim: shiftwidth=2 expandtab tabstop=2 cindent
473// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
474// 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_commit_hash[]
const char darktable_package_version[]
#define DT_BUILD_CHANNEL
#define DT_BUILD_TYPE
#define DT_BUILD_C_FLAGS
int dt_conf_get_bool(const char *name)
const char * dt_install_id(void)
Definition darktable.c:328
darktable_t darktable
Definition darktable.c:183
const char * dt_session_id(void)
Definition darktable.c:314
void dt_print(dt_debug_thread_t thread, const char *msg,...)
Definition darktable.c:1600
@ DT_DEBUG_CONTROL
Definition darktable.h:738
static double dt_get_wtime(void)
Definition darktable.h:976
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:2835
static const char *const mon[12]
Definition strptime.c:99
struct dt_gui_gtk_t * gui
Definition darktable.h:803
struct dt_sys_resources_t dtresources
Definition darktable.h:862
struct dt_opencl_t * opencl
Definition darktable.h:813
double start_wtime
Definition darktable.h:856
double dpi
Definition gtk.h:205
double ppd
Definition gtk.h:205
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:162
int num_devs
Definition opencl.h:263
dt_opencl_device_t * dev
Definition opencl.h:273
int inited
Definition opencl.h:259
void dt_telemetry_record_module_usage(const char *category, const char *name)
Definition telemetry.c:460
void dt_telemetry_shutdown(void)
Definition telemetry.c:451
void dt_telemetry_capture(const char *event, JsonObject *properties)
Definition telemetry.c:455
void dt_telemetry_init(const gboolean have_gui)
Definition telemetry.c:447
void dt_telemetry_record_file_type(const struct dt_image_t *img, const char *pipeline)
Definition telemetry.c:464