fraim 2.0.361 → 2.0.362-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AnalyticsReadQueue = void 0;
|
|
4
|
+
exports.analyticsRetryAfterMs = analyticsRetryAfterMs;
|
|
5
|
+
exports.analyticsReadErrorResponse = analyticsReadErrorResponse;
|
|
6
|
+
/** Admission and retry belong to analytics reads, never telemetry writes. */
|
|
7
|
+
function analyticsRetryAfterMs(error) {
|
|
8
|
+
const message = String(error?.message || '');
|
|
9
|
+
if (error?.code !== 16500 && error?.code !== 429 && error?.status !== 429 &&
|
|
10
|
+
error?.statusCode !== 429 && !/TooManyRequests|\b429\b/.test(message))
|
|
11
|
+
return undefined;
|
|
12
|
+
const delay = Number(error?.RetryAfterMs ?? error?.retryAfterMs ??
|
|
13
|
+
message.match(/RetryAfterMs\s*[=:]\s*(\d+)/i)?.[1] ?? 100);
|
|
14
|
+
return Number.isFinite(delay) && delay >= 0 ? delay : 100;
|
|
15
|
+
}
|
|
16
|
+
/** Keep retry advice and public error messages consistent across analytics routes. */
|
|
17
|
+
function analyticsReadErrorResponse(error) {
|
|
18
|
+
const status = [400, 401, 403, 404, 503].includes(error?.status) ? error.status : 500;
|
|
19
|
+
const headers = {};
|
|
20
|
+
if (status === 503 && error.retryAfterSeconds)
|
|
21
|
+
headers['Retry-After'] = String(error.retryAfterSeconds);
|
|
22
|
+
return { status, headers, body: { error: status === 500 ? 'Internal server error' : error.message } };
|
|
23
|
+
}
|
|
24
|
+
class AnalyticsReadQueue {
|
|
25
|
+
constructor(concurrency = 4) {
|
|
26
|
+
this.concurrency = concurrency;
|
|
27
|
+
this.active = 0;
|
|
28
|
+
this.waiting = [];
|
|
29
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
30
|
+
throw new Error('Invalid read concurrency');
|
|
31
|
+
}
|
|
32
|
+
async run(read) {
|
|
33
|
+
if (this.active >= this.concurrency)
|
|
34
|
+
await new Promise(resolve => this.waiting.push(resolve));
|
|
35
|
+
else
|
|
36
|
+
this.active++;
|
|
37
|
+
try {
|
|
38
|
+
for (let attempt = 0;; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
return await read();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const delay = analyticsRetryAfterMs(error);
|
|
44
|
+
if (delay === undefined)
|
|
45
|
+
throw error;
|
|
46
|
+
console.info(`[analytics] throttle=429 attempt=${attempt + 1} retry_after_ms=${delay}`);
|
|
47
|
+
// Do not retry early when Cosmos asks for a longer pause than
|
|
48
|
+
// this request's bounded retry budget. Return that pause to the caller.
|
|
49
|
+
if (attempt >= 2 || delay > 2000) {
|
|
50
|
+
throw Object.assign(new Error('Analytics is busy. Please retry shortly.'), {
|
|
51
|
+
status: 503, retryAfterSeconds: Math.max(1, Math.ceil(delay / 1000)), cause: error,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
const next = this.waiting.shift();
|
|
60
|
+
if (next)
|
|
61
|
+
next();
|
|
62
|
+
else
|
|
63
|
+
this.active--;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.AnalyticsReadQueue = AnalyticsReadQueue;
|
|
@@ -206,6 +206,27 @@ class UsageAnalyticsService {
|
|
|
206
206
|
}
|
|
207
207
|
return dominant;
|
|
208
208
|
}
|
|
209
|
+
async getTeamMemberSummaries(period, userIds, repoIdentifier, startDate, endDate) {
|
|
210
|
+
const data = await this.dbService.getTeamMemberSummaryData(userIds, this.resolveTimeWindow(period, startDate, endDate), repoIdentifier);
|
|
211
|
+
// Resolve each distinct job once; historical categories follow the current catalog.
|
|
212
|
+
const categories = new Map();
|
|
213
|
+
for (const row of data) {
|
|
214
|
+
for (const component of row.components) {
|
|
215
|
+
const key = JSON.stringify([component.name, component.category]);
|
|
216
|
+
if (!categories.has(key))
|
|
217
|
+
categories.set(key, await this.resolveCanonicalCategory(component.name, 'job', component.category) || 'uncategorized');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return data.map(({ components, ...summary }) => {
|
|
221
|
+
const totals = new Map();
|
|
222
|
+
for (const component of components) {
|
|
223
|
+
const category = categories.get(JSON.stringify([component.name, component.category])) || 'uncategorized';
|
|
224
|
+
totals.set(category, (totals.get(category) ?? 0) + component.count);
|
|
225
|
+
}
|
|
226
|
+
const dominant = [...totals].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
|
|
227
|
+
return { ...summary, dominantCategory: dominant?.[0] ?? null, dominantCategoryCount: dominant?.[1] ?? 0 };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
209
230
|
/**
|
|
210
231
|
* Export usage data as CSV
|
|
211
232
|
*/
|