• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CenterForOpenScience / ember-osf-web / 13206780108

07 Feb 2025 07:33PM UTC coverage: 66.63% (-0.2%) from 66.833%
13206780108

Pull #2490

github

web-flow
Merge f400645ac into 7005478ea
Pull Request #2490: [wip][ENG-6953][ENG-6954][ENG-6955] send usage metrics to datacite

3113 of 5101 branches covered (61.03%)

Branch coverage included in aggregate %.

18 of 53 new or added lines in 4 files covered. (33.96%)

13 existing lines in 2 files now uncovered.

7915 of 11450 relevant lines covered (69.13%)

189.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.51
/app/services/analytics.ts
1
/* eslint-disable max-classes-per-file */
2
import { assert, debug, runInDebug } from '@ember/debug';
3
import { action } from '@ember/object';
4
import RouterService from '@ember/routing/router-service';
5
import RouteInfo from '@ember/routing/-private/route-info';
6
import Service, { inject as service } from '@ember/service';
7
import { waitFor } from '@ember/test-waiters';
8
import Store from '@ember-data/store';
9
import Ember from 'ember';
10
import { restartableTask, task, waitForQueue } from 'ember-concurrency';
11
import { taskFor } from 'ember-concurrency-ts';
12
import Cookies from 'ember-cookies/services/cookies';
13
import config from 'ember-osf-web/config/environment';
14
import Metrics from 'ember-metrics/services/metrics';
15
import Session from 'ember-simple-auth/services/session';
16
import Toast from 'ember-toastr/services/toast';
17
import moment from 'moment-timezone';
18

19
import FileModel from 'ember-osf-web/models/file';
20
import CurrentUser from 'ember-osf-web/services/current-user';
21
import Ready from 'ember-osf-web/services/ready';
22
import captureException from 'ember-osf-web/utils/capture-exception';
23

24
const {
25
    metricsAdapters,
26
    OSF: {
27
        analyticsAttrs,
28
        apiUrl,
29
        cookies: {
30
            cookieConsent: cookieConsentCookie,
31
            keenSessionId: sessionIdCookie,
32
        },
33
    },
34
} = config;
1✔
35

36
export interface TrackedData {
37
    category?: string;
38
    action?: string;
39
    extra?: string;
40
    label: string;
41
    nonInteraction?: boolean;
42
}
43

44
export interface InitialEventInfo {
45
    name?: string;
46
    category?: string;
47
    action?: string;
48
    extra?: string;
49
    nonInteraction?: boolean;
50
}
51

52
export interface RouteMetricsMetadata {
53
    itemGuid?: string;
54
    isSearch?: boolean;
55
    providerId?: string;
56
}
57

58
enum DataCiteMetricType {
59
    View = 'view',
60
    Download = 'download',
61
}
62

63
type PageviewActionLabel = 'web' | 'view' | 'search';
64

65
function logEvent(analytics: Analytics, title: string, data: object) {
66
    runInDebug(() => {
925✔
67
        const logMessage = Object.entries(data)
925✔
68
            .map(([k, v]) => `${k}: ${v}`)
5,055✔
69
            .join(', ');
70
        debug(`${title}: ${logMessage}`);
925✔
71
    });
72

73
    if (analytics.shouldToastOnEvent) {
925!
UNCOV
74
        analytics.toast.info(
×
75
            Object.entries(data)
UNCOV
76
                .filter(([_, v]) => v !== undefined)
×
77
                .map(([k, v]) => `<div>${k}: <strong>${v}</strong></div>`)
×
78
                .join(''),
79
            title,
80
            { preventDuplicates: false },
81
        );
82
    }
83
}
84

85
class EventInfo {
86
    scopes: string[] = [];
965✔
87
    name?: string;
88
    category?: string;
89
    action?: string;
90
    extra?: string;
91
    nonInteraction?: boolean;
92

93
    constructor(targetElement: Element, rootElement: Element, initialInfo?: InitialEventInfo) {
94
        if (initialInfo) {
965!
95
            Object.assign(this, initialInfo);
965✔
96
        }
97
        this.gatherMetadata(targetElement, rootElement);
965✔
98
    }
99

100
    isValid(): boolean {
101
        return Boolean(this.name && this.scopes.length);
965✔
102
    }
103

104
    gatherMetadata(targetElement: Element, rootElement: Element) {
105
        let element: Element | null = targetElement;
965✔
106
        while (element && element !== rootElement) {
965✔
107
            this._gatherMetadataFromElement(element);
8,740✔
108
            element = element.parentElement;
8,740✔
109
        }
110
    }
111

112
    trackedData(): TrackedData {
113
        return {
409✔
114
            category: this.category,
115
            action: this.action,
116
            label: [...this.scopes.reverse(), this.name].join(' - '),
117
            extra: this.extra,
118
            nonInteraction: this.nonInteraction,
119
        };
120
    }
121

122
    _gatherMetadataFromElement(element: Element) {
123
        if (element.hasAttribute(analyticsAttrs.name)) {
8,740✔
124
            assert(
601✔
125
                `Multiple names found for an event! ${this.name} and ${element.getAttribute(analyticsAttrs.name)}`,
126
                !this.name,
127
            );
128
            this.name = element.getAttribute(analyticsAttrs.name)!;
601✔
129

130
            this._gatherAction(element);
601✔
131
            this._gatherExtra(element);
601✔
132
            this._gatherCategory(element);
601✔
133
        } else if (element.hasAttribute(analyticsAttrs.scope)) {
8,139✔
134
            this.scopes.push(element.getAttribute(analyticsAttrs.scope)!);
682✔
135
        }
136
    }
137

138
    _gatherAction(element: Element) {
139
        if (element.hasAttribute(analyticsAttrs.action)) {
601!
UNCOV
140
            this.action = element.getAttribute(analyticsAttrs.action)!;
×
141
        }
142
    }
143

144
    _gatherExtra(element: Element) {
145
        if (element.hasAttribute(analyticsAttrs.extra)) {
601✔
146
            this.extra = element.getAttribute(analyticsAttrs.extra)!;
3✔
147
        }
148
    }
149

150
    _gatherCategory(element: Element) {
151
        if (element.hasAttribute(analyticsAttrs.category)) {
601✔
152
            this.category = element.getAttribute(analyticsAttrs.category)!;
9✔
153
        } else if (element.hasAttribute('role')) {
592✔
154
            this.category = element.getAttribute('role')!;
80✔
155
        } else {
156
            switch (element.tagName) {
512!
157
            case 'BUTTON':
158
                this.category = 'button';
404✔
159
                break;
404✔
160
            case 'A':
161
                this.category = 'link';
79✔
162
                break;
79✔
163
            case 'INPUT':
164
                if (element.hasAttribute('type')) {
29!
165
                    this.category = element.getAttribute('type')!;
29✔
166
                }
167
                break;
29✔
168
            default:
169
            }
170
        }
171

172
        assert('Event category could not be inferred. It must be set explicitly.', Boolean(this.category));
601✔
173
    }
174
}
175

176
export default class Analytics extends Service {
177
    @service metrics!: Metrics;
178
    @service session!: Session;
179
    @service ready!: Ready;
180
    @service router!: RouterService;
181
    @service toast!: Toast;
182
    @service cookies!: Cookies;
183
    @service currentUser!: CurrentUser;
184
    @service store!: Store;
185

186
    shouldToastOnEvent = false;
988✔
187

188
    rootElement?: Element;
189

190
    @restartableTask
191
    @waitFor
192
    async trackPageTask(
193
        pagePublic: boolean | undefined,
194
        resourceType: string,
195
        withdrawn: string,
196
        versionType: string,
197
    ) {
198
        // Wait until everything has settled
199
        await waitForQueue('destroy');
477✔
200

201
        // osf metrics
202
        await this._sendCountedUsage(this._getPageviewPayload());
477✔
203

204
        // datacite usage tracker
205
        this._sendDataciteView();
473✔
206

207
        const eventParams = {
473✔
208
            page: this.router.currentURL,
209
            title: this.router.currentRouteName,
210
        };
211

212
        logEvent(this, 'Tracked page', {
473✔
213
            pagePublic,
214
            resourceType,
215
            withdrawn,
216
            versionType,
217
            ...eventParams,
218
        });
219

220
        const gaConfig = metricsAdapters.findBy('name', 'GoogleAnalytics');
473✔
221
        if (gaConfig) {
473!
222
            const {
223
                authenticated,
224
                isPublic,
225
                resource,
226
                isWithdrawn,
227
                version,
228
            } = gaConfig.dimensions!;
473✔
229

230
            let isPublicValue = 'n/a';
473✔
231
            if (typeof pagePublic !== 'undefined') {
473!
UNCOV
232
                isPublicValue = pagePublic ? 'public' : 'private';
×
233
            }
234

235
            /*
236
              There's supposed to be a document describing how dimensions should be handled, but it doesn't exist yet.
237
              When it does, we'll replace out this comment with the link to that documentation. For now:
238
                  1) isPublic: public, private, or n/a (for pages that aren't covered by app permissions like the
239
                  dashboard;
240
                  2) authenticated: Logged in or Logged out
241
                  3) resource: the JSONAPI type (node, file, user, etc) or n/a
242
            */
243
            this.metrics.trackPage('GoogleAnalytics', {
473✔
244
                [authenticated]: this.session.isAuthenticated ? 'Logged in' : 'Logged out',
473✔
245
                [isPublic]: isPublicValue,
246
                [resource]: resourceType,
247
                [isWithdrawn]: withdrawn,
248
                [version]: versionType,
249
                ...eventParams,
250
            });
251
        }
252

253
        this.metrics.trackPage('Keen', {
473✔
254
            pagePublic,
255
            ...eventParams,
256
        });
257
    }
258

259
    @task
260
    @waitFor
261
    async _trackDownloadTask(itemGuid: string, doi?: string) {
NEW
262
        const _doi = doi || await this._getDoiForGuid(itemGuid);
×
NEW
263
        if (_doi) {
×
NEW
264
            this._sendDataciteUsage(_doi, DataCiteMetricType.Download);
×
265
        }
266
    }
267

268
    @action
269
    click(category: string, label: string, extraInfo?: string | object) {
270
        let extra = extraInfo;
12✔
271
        if (extra && typeof extra !== 'string') {
12!
272
            // This is to remove the event object when used with onclick
UNCOV
273
            extra = undefined;
×
274
        }
275
        this._trackEvent({
12✔
276
            category,
277
            action: 'click',
278
            label,
279
            extra,
280
        });
281

282
        return true;
12✔
283
    }
284

285
    track(category: string, actionName: string, label: string, extraInfo?: string) {
286
        let extra = extraInfo;
31✔
287
        if (extra && typeof extra !== 'string') {
31!
UNCOV
288
            extra = undefined;
×
289
        }
290

291
        this._trackEvent({
31✔
292
            category,
293
            action: actionName,
294
            label,
295
            extra,
296
        });
297

298
        return true;
31✔
299
    }
300

301
    trackPage(
302
        this: Analytics,
303
        pagePublic?: boolean,
304
        resourceType = 'n/a',
477✔
305
        withdrawn = 'n/a',
477✔
306
        version = 'n/a',
477✔
307
    ) {
308
        taskFor(this.trackPageTask).perform(pagePublic, resourceType, withdrawn, version);
477✔
309
    }
310

311
    trackDownload(itemGuid: string, doi?: string) {
NEW
UNCOV
312
        if (!Ember.testing) {
×
NEW
UNCOV
313
            taskFor(this._trackDownloadTask).perform(itemGuid, doi);
×
314
        }
315
    }
316

317
    trackFromElement(target: Element, initialInfo: InitialEventInfo) {
318
        assert(
19✔
319
            'rootElement not set! Check that instance-initializers/analytics ran',
320
            Boolean(this.rootElement),
321
        );
322
        const eventInfo = new EventInfo(target, this.rootElement!, initialInfo);
19✔
323

324
        if (eventInfo.isValid()) {
19✔
325
            this._trackEvent(eventInfo.trackedData());
14✔
326
        }
327
    }
328

329
    handleClick(e: MouseEvent) {
330
        assert(
946✔
331
            'rootElement not set! Check that instance-initializers/analytics ran',
332
            Boolean(this.rootElement),
333
        );
334
        if (e.target) {
946!
335
            const eventInfo = new EventInfo(e.target as Element, this.rootElement!, {
946✔
336
                action: e.type,
337
            });
338

339
            if (eventInfo.isValid()) {
946✔
340
                this._trackEvent(eventInfo.trackedData());
395✔
341
            }
342
        }
343
    }
344

345
    _trackEvent(trackedData: TrackedData) {
346
        this.metrics.trackEvent(trackedData);
452✔
347

348
        logEvent(this, 'Tracked event', trackedData);
452✔
349
    }
350

351
    async _sendCountedUsage(payload: object) {
352
        await this.currentUser.authenticatedAJAX({
477✔
353
            method: 'POST',
354
            url: `${apiUrl}/_/metrics/events/counted_usage/`,
355
            data: JSON.stringify(payload),
356
            headers: {
357
                'Content-Type': 'application/vnd.api+json',
358
            },
359
        });
360
    }
361

362
    _getPageviewPayload() {
363
        const routeMetricsMetadata = this._getRouteMetricsMetadata();
477✔
364
        const all_attrs = {
477✔
365
            item_guid: routeMetricsMetadata.itemGuid,
366
            provider_id: routeMetricsMetadata.providerId,
367
            action_labels: this._getPageviewActionLabels(routeMetricsMetadata),
368
            client_session_id: this._sessionId,
369
        } as const;
370
        const attributes = Object.fromEntries(
477✔
371
            Object.entries(all_attrs).filter(
372
                ([_,value]: [unknown, unknown]) => (typeof value !== 'undefined'),
1,908✔
373
            ),
374
        );
375
        return {
477✔
376
            data: {
377
                type: 'counted-usage',
378
                attributes: {
379
                    ...attributes,
380
                    pageview_info: {
381
                        page_url: document.URL,
382
                        page_title: document.title,
383
                        referer_url: document.referrer,
384
                        route_name: `ember-osf-web.${this.router.currentRouteName}`,
385
                    },
386
                },
387
            },
388
        };
389
    }
390

391
    get _sessionId() {
392
        if (!this.cookies.exists(cookieConsentCookie)) {
477!
393
            return undefined;
477✔
394
        }
395
        const sessionId = (
UNCOV
396
            this.cookies.read(sessionIdCookie)
×
397
            || ('randomUUID' in crypto && (crypto as any).randomUUID())
398
            || Math.random().toString()
399
        );
UNCOV
400
        this.cookies.write(sessionIdCookie, sessionId, {
×
401
            expires: moment().add(25, 'minutes').toDate(),
402
            path: '/',
403
        });
UNCOV
404
        return sessionId;
×
405
    }
406

407
    _getRouteMetricsMetadata(): RouteMetricsMetadata {
408
        // build list of `osfMetrics` values from all current active routes
409
        // for merging, ordered from root to leaf (so values from leafier
410
        // routes can override those from rootier routes)
411
        const metricsMetadatums = [];
950✔
412
        let currentRouteInfo: RouteInfo | null = this.router.currentRoute;
950✔
413
        while (currentRouteInfo) {
950✔
414
            const { metadata } = currentRouteInfo as any;
3,564✔
415
            if (metadata && metadata.osfMetrics) {
3,564✔
416
                metricsMetadatums.unshift(metadata.osfMetrics);
640✔
417
            }
418
            currentRouteInfo = currentRouteInfo.parent;
3,564✔
419
        }
420
        const mergedMetricsMetadata = Object.assign({}, ...metricsMetadatums);
950✔
421
        return mergedMetricsMetadata;
950✔
422
    }
423

424
    _getPageviewActionLabels(routeMetricsMetadata: RouteMetricsMetadata): PageviewActionLabel[] {
425
        const actionLabelMap: Record<PageviewActionLabel, Boolean> = {
477✔
426
            web: true,
427
            view: Boolean(routeMetricsMetadata.itemGuid),
428
            search: Boolean(routeMetricsMetadata.isSearch),
429
        };
430
        const labels = Object.keys(actionLabelMap) as PageviewActionLabel[];
477✔
431
        return labels.filter(label => actionLabelMap[label]);
1,431✔
432
    }
433

434
    async _sendDataciteView(): Promise<void> {
435
        const { itemGuid } = this._getRouteMetricsMetadata();
473✔
436
        if (itemGuid && !Ember.testing) {
473!
NEW
437
            const doi = await this._getDoiForGuid(itemGuid);
×
NEW
438
            this._sendDataciteUsage(doi, DataCiteMetricType.View);
×
439
        }
440
    }
441

442
    /*
443
        * Reimplements Datacite's usage tracking API.
444
        * https://github.com/datacite/datacite-tracker/blob/main/src/lib/request.ts
445
    */
446
    async _sendDataciteUsage(
447
        doi: string,
448
        metricType: DataCiteMetricType,
449
    ) {
NEW
450
        try {
×
NEW
451
            const { dataCiteTrackerUrl, dataciteTrackerRepoId } = config.OSF;
×
NEW
452
            if (dataciteTrackerRepoId && doi) {
×
NEW
453
                const payload = {
×
454
                    n: metricType,
455
                    u: location.href,
456
                    i: dataciteTrackerRepoId,
457
                    p: doi,
458
                };
459

NEW
460
                await fetch(dataCiteTrackerUrl, {
×
461
                    method: 'POST',
462
                    headers: {
463
                        'Content-Type': 'application/json',
464
                    },
465
                    body: JSON.stringify(payload),
466
                });
467
            }
468
        } catch (e) {
NEW
469
            captureException(e);
×
470
        }
471
    }
472

473
    async _getDoiForGuid(itemGuid: string): Promise<string> {
NEW
474
        const _guid = await this.store.findRecord('guid', itemGuid);
×
NEW
475
        if (!_guid) {
×
NEW
476
            return '';
×
477
        }
478

NEW
479
        const _item = await _guid.resolve();
×
NEW
480
        if (!_item) {
×
NEW
481
            return '';
×
482
        }
NEW
483
        return this._getDoiForItem(_item);
×
484
    }
485

486
    async _getDoiForItem(item: any): Promise<string> {
NEW
487
        const _identifiers = (await item.identifiers)?.toArray();
×
NEW
488
        if (_identifiers) {
×
NEW
489
            for (const _identifier of _identifiers) {
×
NEW
490
                if (_identifier.category === 'doi') {
×
NEW
491
                    return _identifier.value;
×
492
                }
493
            }
494
        }
NEW
495
        if (item instanceof FileModel) {
×
NEW
496
            const _fileContainer = await item.target;
×
NEW
497
            if (_fileContainer) {
×
NEW
498
                return this._getDoiForItem(_fileContainer);
×
499
            }
500
        }
NEW
501
        return '';
×
502
    }
503
}
504

505
declare module '@ember/service' {
506
    interface Registry {
507
        'analytics': Analytics;
508
    }
509
}
510
/* eslint-enable max-classes-per-file */
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc