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

CenterForOpenScience / ember-osf-web / 13662274701

04 Mar 2025 08:24PM UTC coverage: 66.57%. First build
13662274701

Pull #2519

github

web-flow
Merge 8efe3be32 into 0d3673ad9
Pull Request #2519: [ENG-7337] Send metrics for unverified DOIs

3129 of 5135 branches covered (60.93%)

Branch coverage included in aggregate %.

1 of 3 new or added lines in 2 files covered. (33.33%)

7941 of 11494 relevant lines covered (69.09%)

191.27 hits per line

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

60.35
/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
    itemDoi?: string;
55
    isSearch?: boolean;
56
    providerId?: string;
57
}
58

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

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

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

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

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

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

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

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

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

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

131
            this._gatherAction(element);
609✔
132
            this._gatherExtra(element);
609✔
133
            this._gatherCategory(element);
609✔
134
        } else if (element.hasAttribute(analyticsAttrs.scope)) {
8,246✔
135
            this.scopes.push(element.getAttribute(analyticsAttrs.scope)!);
695✔
136
        }
137
    }
138

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

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

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

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

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

187
    shouldToastOnEvent = false;
989✔
188

189
    rootElement?: Element;
190

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

202
        // osf metrics
203
        await this._sendCountedUsage(this._getPageviewPayload());
479✔
204

205
        // datacite usage tracker
206
        if (!Ember.testing) {
475!
207
            this._sendDataciteView();
×
208
        }
209

210
        const eventParams = {
475✔
211
            page: this.router.currentURL,
212
            title: this.router.currentRouteName,
213
        };
214

215
        logEvent(this, 'Tracked page', {
475✔
216
            pagePublic,
217
            resourceType,
218
            withdrawn,
219
            versionType,
220
            ...eventParams,
221
        });
222

223
        const gaConfig = metricsAdapters.findBy('name', 'GoogleAnalytics');
475✔
224
        if (gaConfig) {
475!
225
            const {
226
                authenticated,
227
                isPublic,
228
                resource,
229
                isWithdrawn,
230
                version,
231
            } = gaConfig.dimensions!;
475✔
232

233
            let isPublicValue = 'n/a';
475✔
234
            if (typeof pagePublic !== 'undefined') {
475!
235
                isPublicValue = pagePublic ? 'public' : 'private';
×
236
            }
237

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

256
        this.metrics.trackPage('Keen', {
475✔
257
            pagePublic,
258
            ...eventParams,
259
        });
260
    }
261

262
    @task
263
    @waitFor
264
    async _trackDownloadTask(itemGuid: string, doi?: string) {
265
        // if doi is undefined/null, try finding a DOI via the api based on itemGuid
266
        // (if doi is an empty string, assume there's no DOI; don't try to find one)
NEW
267
        const _doi = doi ?? await this._getDoiForGuid(itemGuid);
×
268
        if (_doi) {
×
269
            this._sendDataciteUsage(_doi, DataCiteMetricType.Download);
×
270
        }
271
    }
272

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

287
        return true;
12✔
288
    }
289

290
    track(category: string, actionName: string, label: string, extraInfo?: string) {
291
        let extra = extraInfo;
31✔
292
        if (extra && typeof extra !== 'string') {
31!
293
            extra = undefined;
×
294
        }
295

296
        this._trackEvent({
31✔
297
            category,
298
            action: actionName,
299
            label,
300
            extra,
301
        });
302

303
        return true;
31✔
304
    }
305

306
    trackPage(
307
        this: Analytics,
308
        pagePublic?: boolean,
309
        resourceType = 'n/a',
479✔
310
        withdrawn = 'n/a',
479✔
311
        version = 'n/a',
479✔
312
    ) {
313
        taskFor(this.trackPageTask).perform(pagePublic, resourceType, withdrawn, version);
479✔
314
    }
315

316
    trackDownload(itemGuid: string, doi?: string) {
317
        if (!Ember.testing) {
×
318
            taskFor(this._trackDownloadTask).perform(itemGuid, doi);
×
319
        }
320
    }
321

322
    trackFromElement(target: Element, initialInfo: InitialEventInfo) {
323
        assert(
19✔
324
            'rootElement not set! Check that instance-initializers/analytics ran',
325
            Boolean(this.rootElement),
326
        );
327
        const eventInfo = new EventInfo(target, this.rootElement!, initialInfo);
19✔
328

329
        if (eventInfo.isValid()) {
19✔
330
            this._trackEvent(eventInfo.trackedData());
14✔
331
        }
332
    }
333

334
    handleClick(e: MouseEvent) {
335
        assert(
954✔
336
            'rootElement not set! Check that instance-initializers/analytics ran',
337
            Boolean(this.rootElement),
338
        );
339
        if (e.target) {
954!
340
            const eventInfo = new EventInfo(e.target as Element, this.rootElement!, {
954✔
341
                action: e.type,
342
            });
343

344
            if (eventInfo.isValid()) {
954✔
345
                this._trackEvent(eventInfo.trackedData());
402✔
346
            }
347
        }
348
    }
349

350
    _trackEvent(trackedData: TrackedData) {
351
        this.metrics.trackEvent(trackedData);
459✔
352

353
        logEvent(this, 'Tracked event', trackedData);
459✔
354
    }
355

356
    async _sendCountedUsage(payload: object) {
357
        await this.currentUser.authenticatedAJAX({
479✔
358
            method: 'POST',
359
            url: `${apiUrl}/_/metrics/events/counted_usage/`,
360
            data: JSON.stringify(payload),
361
            headers: {
362
                'Content-Type': 'application/vnd.api+json',
363
            },
364
        });
365
    }
366

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

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

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

429
    _getPageviewActionLabels(routeMetricsMetadata: RouteMetricsMetadata): PageviewActionLabel[] {
430
        const actionLabelMap: Record<PageviewActionLabel, Boolean> = {
479✔
431
            web: true,
432
            view: Boolean(routeMetricsMetadata.itemGuid),
433
            search: Boolean(routeMetricsMetadata.isSearch),
434
        };
435
        const labels = Object.keys(actionLabelMap) as PageviewActionLabel[];
479✔
436
        return labels.filter(label => actionLabelMap[label]);
1,437✔
437
    }
438

439
    async _sendDataciteView(): Promise<void> {
440
        const { itemGuid, itemDoi } = this._getRouteMetricsMetadata();
×
441
        // if itemDoi is undefined/null, try finding a DOI via the api based on itemGuid
442
        // (if itemDoi is an empty string, assume there's no DOI; don't try to find one)
NEW
443
        const _doi = itemDoi ?? await this._getDoiForGuid(itemGuid);
×
444
        if (_doi) {
×
445
            this._sendDataciteUsage(_doi, DataCiteMetricType.View);
×
446
        }
447
    }
448

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

467
                await fetch(dataCiteTrackerUrl, {
×
468
                    method: 'POST',
469
                    headers: {
470
                        'Content-Type': 'application/json',
471
                    },
472
                    body: JSON.stringify(payload),
473
                });
474
            }
475
        } catch (e) {
476
            captureException(e);
×
477
        }
478
    }
479

480
    async _getDoiForGuid(itemGuid?: string): Promise<string> {
481
        if (itemGuid) {
×
482
            const _guid = await this.store.findRecord('guid', itemGuid);
×
483
            if (_guid) {
×
484
                const _item = await _guid.resolve();
×
485
                if (_item) {
×
486
                    return this._getDoiForItem(_item);
×
487
                }
488
            }
489
        }
490
        return '';
×
491
    }
492

493
    async _getDoiForItem(item: any): Promise<string> {
494
        const _identifiers = (await item.identifiers)?.toArray();
×
495
        if (_identifiers) {
×
496
            for (const _identifier of _identifiers) {
×
497
                if (_identifier.category === 'doi') {
×
498
                    return _identifier.value;
×
499
                }
500
            }
501
        }
502
        if (item instanceof FileModel) {
×
503
            const _fileContainer = await item.target;
×
504
            if (_fileContainer) {
×
505
                return this._getDoiForItem(_fileContainer);
×
506
            }
507
        }
508
        return '';
×
509
    }
510
}
511

512
declare module '@ember/service' {
513
    interface Registry {
514
        'analytics': Analytics;
515
    }
516
}
517
/* 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