pixivflow 2.22.1 → 2.23.0
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.
- package/dist/commands/SchedulerCommand.js +12 -3
- package/dist/commands/scheduler-runtime.d.ts +29 -0
- package/dist/commands/scheduler-runtime.js +54 -1
- package/dist/download/exec/DownloadExecutor.js +11 -2
- package/dist/download/handlers/IllustrationTargetHandler.js +15 -0
- package/dist/download/handlers/NovelTargetHandler.js +12 -2
- package/dist/interfaces/IDatabase.d.ts +11 -0
- package/dist/logger.d.ts +24 -0
- package/dist/logger.js +88 -11
- package/dist/observability/classify.d.ts +7 -0
- package/dist/observability/classify.js +51 -0
- package/dist/observability/context.d.ts +10 -0
- package/dist/observability/context.js +22 -0
- package/dist/observability/index.d.ts +7 -0
- package/dist/observability/index.js +36 -0
- package/dist/observability/types.d.ts +23 -0
- package/dist/observability/types.js +3 -0
- package/dist/package.json +1 -1
- package/dist/scheduler/RecoveryOutcome.d.ts +24 -0
- package/dist/scheduler/RecoveryOutcome.js +44 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +4 -0
- package/dist/scheduler/SlotBusinessStatus.d.ts +32 -0
- package/dist/scheduler/SlotBusinessStatus.js +43 -0
- package/dist/scheduler/SlotCoordinator.d.ts +7 -0
- package/dist/scheduler/SlotCoordinator.js +13 -0
- package/dist/storage/Database.d.ts +4 -0
- package/dist/storage/Database.js +7 -0
- package/dist/storage/DatabaseMigration.js +21 -0
- package/dist/storage/repositories/SystemErrorRepository.d.ts +19 -0
- package/dist/storage/repositories/SystemErrorRepository.js +92 -0
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/dist/webui/routes/admin-logs.d.ts +3 -0
- package/dist/webui/routes/admin-logs.js +9 -0
- package/dist/webui/routes/handlers/admin-logs-handlers.d.ts +6 -0
- package/dist/webui/routes/handlers/admin-logs-handlers.js +106 -0
- package/dist/webui/routes/handlers/system-errors-handlers.d.ts +6 -0
- package/dist/webui/routes/handlers/system-errors-handlers.js +59 -0
- package/dist/webui/routes/system-errors.d.ts +3 -0
- package/dist/webui/routes/system-errors.js +9 -0
- package/dist/webui/server/server-routes.js +5 -0
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ const ScheduleTriggerServer_1 = require("../scheduler/ScheduleTriggerServer");
|
|
|
12
12
|
const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
|
|
13
13
|
const schedules_1 = require("../scheduler/schedules");
|
|
14
14
|
const scheduler_runtime_1 = require("./scheduler-runtime");
|
|
15
|
+
const RecoveryOutcome_1 = require("../scheduler/RecoveryOutcome");
|
|
15
16
|
const SchedulerIdleLifecycle_1 = require("./SchedulerIdleLifecycle");
|
|
16
17
|
/**
|
|
17
18
|
* Decide whether the authenticated HTTP trigger server should be mounted. It is
|
|
@@ -263,9 +264,17 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
263
264
|
recoverStatus: (targetId, requestId) => {
|
|
264
265
|
const slot = runtime.database.slots.findRecoverySlot(requestId, targetId);
|
|
265
266
|
const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
if (!slot || !cell)
|
|
268
|
+
return null;
|
|
269
|
+
const businessState = (0, RecoveryOutcome_1.recoveryOutcomeFor)(cell);
|
|
270
|
+
return {
|
|
271
|
+
requestId,
|
|
272
|
+
slotId: slot.id,
|
|
273
|
+
state: cell.status,
|
|
274
|
+
slotStatus: slot.status,
|
|
275
|
+
business_state: businessState,
|
|
276
|
+
message: (0, RecoveryOutcome_1.recoveryUserMessage)(businessState),
|
|
277
|
+
};
|
|
269
278
|
},
|
|
270
279
|
status: (scheduleId) => {
|
|
271
280
|
const cfg = resolveConfig();
|
|
@@ -119,6 +119,35 @@ export interface SchedulerRuntime {
|
|
|
119
119
|
close(): void;
|
|
120
120
|
}
|
|
121
121
|
export declare function notifyScheduleFailure(config: StandaloneConfig, database: Database, schedule: ScheduleConfig, failure: JobFailure): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* A `no_candidate` scan that attempted nothing and whose only candidate skips
|
|
124
|
+
* are `duplicate` is a TRUE dead-end: every candidate the scan surfaced was
|
|
125
|
+
* already delivered, and nothing was attempted to be widened. Advancing the
|
|
126
|
+
* fallback stage for this would re-fetch the SAME stale duplicate pool with a
|
|
127
|
+
* wider bound and burn the whole scheduler budget again (the production bug
|
|
128
|
+
* that turned an empty day on bot1 into a 30-minute OperationCancelledError).
|
|
129
|
+
* Terminalise it immediately instead of entering the fallback re-run loop.
|
|
130
|
+
*/
|
|
131
|
+
export interface CandidateExhaustionDiagnostic {
|
|
132
|
+
stage: 'candidate_selection';
|
|
133
|
+
result: 'no_candidate';
|
|
134
|
+
reason: 'duplicate_exhausted' | 'filter_exhausted' | 'no_candidate';
|
|
135
|
+
searched: number;
|
|
136
|
+
duplicates: number;
|
|
137
|
+
filtered: number;
|
|
138
|
+
attempted: number;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Structured diagnostic for a terminal `no_candidate`: what the scan surfaced
|
|
142
|
+
* (`searched`), how many were already-delivered duplicates, how many were
|
|
143
|
+
* filtered by the run's own rules, and how many were actually attempted.
|
|
144
|
+
* Emitted as a JSON log so a Mini App can show "当天候选均已投稿" instead of
|
|
145
|
+
* treating the slot as a system failure.
|
|
146
|
+
*/
|
|
147
|
+
export declare function candidateExhaustionDiagnostic(outcome: Extract<TargetOutcome, {
|
|
148
|
+
kind: 'no_candidate';
|
|
149
|
+
}>): CandidateExhaustionDiagnostic | null;
|
|
150
|
+
export declare function isDuplicateOnlyDeadEnd(outcome: TargetOutcome): boolean;
|
|
122
151
|
/**
|
|
123
152
|
* Expanded, still-bounded candidate scan bound for one fallback stage
|
|
124
153
|
* (§schedule-recovery). Stage 0 is the primary pass; stage N scans
|
|
@@ -45,6 +45,8 @@ exports.EXECUTION_MODES = void 0;
|
|
|
45
45
|
exports.withDeliveryMode = withDeliveryMode;
|
|
46
46
|
exports.shouldTerminaliseAbortedSlot = shouldTerminaliseAbortedSlot;
|
|
47
47
|
exports.notifyScheduleFailure = notifyScheduleFailure;
|
|
48
|
+
exports.candidateExhaustionDiagnostic = candidateExhaustionDiagnostic;
|
|
49
|
+
exports.isDuplicateOnlyDeadEnd = isDuplicateOnlyDeadEnd;
|
|
48
50
|
exports.fallbackScanLimit = fallbackScanLimit;
|
|
49
51
|
exports.createSchedulerRuntime = createSchedulerRuntime;
|
|
50
52
|
exports.runWithTimeout = runWithTimeout;
|
|
@@ -201,6 +203,44 @@ function buildProxyUrl(network) {
|
|
|
201
203
|
const auth = proxy.username ? `${proxy.username}:${proxy.password ?? ''}@` : '';
|
|
202
204
|
return `${protocol}://${auth}${proxy.host}:${proxy.port}`;
|
|
203
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Structured diagnostic for a terminal `no_candidate`: what the scan surfaced
|
|
208
|
+
* (`searched`), how many were already-delivered duplicates, how many were
|
|
209
|
+
* filtered by the run's own rules, and how many were actually attempted.
|
|
210
|
+
* Emitted as a JSON log so a Mini App can show "当天候选均已投稿" instead of
|
|
211
|
+
* treating the slot as a system failure.
|
|
212
|
+
*/
|
|
213
|
+
function candidateExhaustionDiagnostic(outcome) {
|
|
214
|
+
const scan = outcome.scan;
|
|
215
|
+
if (!scan)
|
|
216
|
+
return null;
|
|
217
|
+
const skipped = scan.skipped;
|
|
218
|
+
const duplicates = skipped.filter((s) => s.code === 'duplicate').length;
|
|
219
|
+
const filtered = skipped.filter((s) => s.code !== 'duplicate' && s.code !== 'unavailable').length;
|
|
220
|
+
const reason = isDuplicateOnlyDeadEnd(outcome)
|
|
221
|
+
? 'duplicate_exhausted'
|
|
222
|
+
: filtered > 0
|
|
223
|
+
? 'filter_exhausted'
|
|
224
|
+
: 'no_candidate';
|
|
225
|
+
return {
|
|
226
|
+
stage: 'candidate_selection',
|
|
227
|
+
result: 'no_candidate',
|
|
228
|
+
reason,
|
|
229
|
+
searched: skipped.length,
|
|
230
|
+
duplicates,
|
|
231
|
+
filtered,
|
|
232
|
+
attempted: scan.attempted,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function isDuplicateOnlyDeadEnd(outcome) {
|
|
236
|
+
if (outcome.kind !== 'no_candidate' || !outcome.scan)
|
|
237
|
+
return false;
|
|
238
|
+
const s = outcome.scan;
|
|
239
|
+
return (s.attempted === 0 &&
|
|
240
|
+
s.outages.length === 0 &&
|
|
241
|
+
s.skipped.length > 0 &&
|
|
242
|
+
s.skipped.every((skip) => skip.code === 'duplicate'));
|
|
243
|
+
}
|
|
204
244
|
/**
|
|
205
245
|
* Expanded, still-bounded candidate scan bound for one fallback stage
|
|
206
246
|
* (§schedule-recovery). Stage 0 is the primary pass; stage N scans
|
|
@@ -536,13 +576,26 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
536
576
|
// an exhausted occurrence reports the true cause — never a generic
|
|
537
577
|
// "target did not complete".
|
|
538
578
|
if (!scheduleSlot.manualRequestId &&
|
|
539
|
-
(outcome.kind === '
|
|
579
|
+
(outcome.kind === 'duplicate' ||
|
|
580
|
+
(outcome.kind === 'no_candidate' && !isDuplicateOnlyDeadEnd(outcome))) &&
|
|
540
581
|
coordinator.cellFallbackStage(scheduleSlot.slotId, target.id) < maxFallbackStages - 1) {
|
|
541
582
|
coordinator.advanceFallback(scheduleSlot.slotId, target.id, outcome.kind === 'duplicate'
|
|
542
583
|
? `duplicate candidates (stage ${coordinator.cellFallbackStage(scheduleSlot.slotId, target.id)})`
|
|
543
584
|
: outcome.reason ?? 'no eligible candidate', maxFallbackStages);
|
|
544
585
|
return;
|
|
545
586
|
}
|
|
587
|
+
if (outcome.kind === 'no_candidate') {
|
|
588
|
+
const diag = candidateExhaustionDiagnostic(outcome);
|
|
589
|
+
if (diag) {
|
|
590
|
+
logger_1.logger.info('candidate_exhaustion', {
|
|
591
|
+
schedule_id: scheduleSlot.scheduleId,
|
|
592
|
+
slot_id: scheduleSlot.slotId,
|
|
593
|
+
bot_id: scheduleSlot.scheduleId?.split('-')[0],
|
|
594
|
+
target_id: target.id,
|
|
595
|
+
...diag,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
546
599
|
coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
|
|
547
600
|
notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
|
|
548
601
|
if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
|
|
@@ -26,8 +26,11 @@ class DownloadExecutor {
|
|
|
26
26
|
itemId: item?.id,
|
|
27
27
|
...(contextProvider?.(item, index) ?? {}),
|
|
28
28
|
};
|
|
29
|
+
const itemId = item?.id ?? item?.pixivId ?? String(item);
|
|
30
|
+
logger_1.logger.info('download_started', { pixiv_id: itemId, attempt, stage: 'download' });
|
|
29
31
|
try {
|
|
30
32
|
const res = await task(item, index);
|
|
33
|
+
logger_1.logger.info('download_completed', { pixiv_id: itemId, attempt, stage: 'download' });
|
|
31
34
|
results[index] = res;
|
|
32
35
|
break;
|
|
33
36
|
}
|
|
@@ -35,21 +38,27 @@ class DownloadExecutor {
|
|
|
35
38
|
onError?.(error, item, index, attempt);
|
|
36
39
|
const decision = recovery.decide(error, effectiveContext);
|
|
37
40
|
onDecision?.(decision, { item, index, attempt, error });
|
|
41
|
+
const status = error?.status;
|
|
42
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
38
43
|
if (decision.action === 'skip') {
|
|
39
|
-
logger_1.logger.warn(
|
|
44
|
+
logger_1.logger.warn(status ? `download_http_failed` : `download_failed`, {
|
|
45
|
+
pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg,
|
|
46
|
+
});
|
|
40
47
|
break;
|
|
41
48
|
}
|
|
42
49
|
if (decision.action === 'fail') {
|
|
50
|
+
logger_1.logger.error(`download_failed`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg });
|
|
43
51
|
throw error instanceof Error ? error : new Error(String(error));
|
|
44
52
|
}
|
|
45
53
|
// retry/backoff
|
|
46
54
|
const delayMs = decision.delayMs ?? 0;
|
|
55
|
+
logger_1.logger.warn(`download_retry`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, retry_delay_ms: delayMs, error: errMsg });
|
|
47
56
|
if (delayMs > 0) {
|
|
48
57
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
49
58
|
}
|
|
50
59
|
attempt += 1;
|
|
51
60
|
if (attempt > (decision.maxAttempts ?? maxAttempts)) {
|
|
52
|
-
logger_1.logger.error(`
|
|
61
|
+
logger_1.logger.error(`download_failed`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg });
|
|
53
62
|
throw error instanceof Error ? error : new Error(String(error));
|
|
54
63
|
}
|
|
55
64
|
}
|
|
@@ -8,6 +8,8 @@ const pixiv_utils_1 = require("../../utils/pixiv-utils");
|
|
|
8
8
|
const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
|
|
9
9
|
const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
|
|
10
10
|
const target_label_1 = require("../../utils/target-label");
|
|
11
|
+
const observability_1 = require("../../observability");
|
|
12
|
+
const context_1 = require("../../observability/context");
|
|
11
13
|
const DownloadPlanner_1 = require("../plan/DownloadPlanner");
|
|
12
14
|
const deliveryContext_1 = require("./deliveryContext");
|
|
13
15
|
class IllustrationTargetHandler {
|
|
@@ -159,6 +161,13 @@ class IllustrationTargetHandler {
|
|
|
159
161
|
classifyError(error, displayTag, mode, target) {
|
|
160
162
|
const message = error instanceof Error ? error.message : String(error);
|
|
161
163
|
const scan = this.scan ?? undefined;
|
|
164
|
+
const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
|
|
165
|
+
(0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)(target, this.execution, {
|
|
166
|
+
message,
|
|
167
|
+
stage: 'pixiv_download',
|
|
168
|
+
...cls,
|
|
169
|
+
trace_id: this.execution?.slotId,
|
|
170
|
+
}), cls);
|
|
162
171
|
// A hard job-level outage is named as such and is never recorded as a
|
|
163
172
|
// no-candidate business outcome, whatever its message happens to look like.
|
|
164
173
|
const outage = (0, TargetOutcome_1.classifyJobLevelOutage)(error);
|
|
@@ -166,6 +175,8 @@ class IllustrationTargetHandler {
|
|
|
166
175
|
logger_1.logger.error(`Illustration ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, {
|
|
167
176
|
error: message,
|
|
168
177
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
178
|
+
error_type: cls.error_type,
|
|
179
|
+
retryable: cls.retryable,
|
|
169
180
|
...(outage ? { jobLevelOutage: outage } : {}),
|
|
170
181
|
});
|
|
171
182
|
if (outage) {
|
|
@@ -508,9 +519,13 @@ class IllustrationTargetHandler {
|
|
|
508
519
|
if (endpoint) {
|
|
509
520
|
errorMessage = `${errorMessage} [URL: ${endpoint}]`;
|
|
510
521
|
}
|
|
522
|
+
const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
|
|
523
|
+
(0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)({ type: 'illustration' }, this.execution, { message: errorMessage, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
|
|
511
524
|
logger_1.logger.error(message, {
|
|
512
525
|
error: errorMessage,
|
|
513
526
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
527
|
+
error_type: cls.error_type,
|
|
528
|
+
retryable: cls.retryable,
|
|
514
529
|
stack: error instanceof Error ? error.stack : undefined,
|
|
515
530
|
});
|
|
516
531
|
}
|
|
@@ -8,6 +8,8 @@ const pixiv_utils_1 = require("../../utils/pixiv-utils");
|
|
|
8
8
|
const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
|
|
9
9
|
const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
|
|
10
10
|
const target_label_1 = require("../../utils/target-label");
|
|
11
|
+
const observability_1 = require("../../observability");
|
|
12
|
+
const context_1 = require("../../observability/context");
|
|
11
13
|
const DownloadPlanner_1 = require("../plan/DownloadPlanner");
|
|
12
14
|
const deliveryContext_1 = require("./deliveryContext");
|
|
13
15
|
class NovelTargetHandler {
|
|
@@ -109,7 +111,7 @@ class NovelTargetHandler {
|
|
|
109
111
|
return this.summarize();
|
|
110
112
|
}
|
|
111
113
|
catch (error) {
|
|
112
|
-
return this.classifyError(error, displayTag, mode);
|
|
114
|
+
return this.classifyError(error, displayTag, mode, target);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
/**
|
|
@@ -165,15 +167,19 @@ class NovelTargetHandler {
|
|
|
165
167
|
...(scan ? { scan } : {}),
|
|
166
168
|
};
|
|
167
169
|
}
|
|
168
|
-
classifyError(error, displayTag, mode) {
|
|
170
|
+
classifyError(error, displayTag, mode, target) {
|
|
169
171
|
const message = error instanceof Error ? error.message : String(error);
|
|
170
172
|
const scan = this.scan ?? undefined;
|
|
173
|
+
const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
|
|
174
|
+
(0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)(target, this.execution, { message, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
|
|
171
175
|
// A hard job-level outage is named as such and is never recorded as a
|
|
172
176
|
// no-candidate business outcome, whatever its message happens to look like.
|
|
173
177
|
const outage = (0, TargetOutcome_1.classifyJobLevelOutage)(error);
|
|
174
178
|
this.database.logExecution(displayTag, 'novel', 'failed', message);
|
|
175
179
|
logger_1.logger.error(`Novel ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, {
|
|
176
180
|
error: message,
|
|
181
|
+
error_type: cls.error_type,
|
|
182
|
+
retryable: cls.retryable,
|
|
177
183
|
...(outage ? { jobLevelOutage: outage } : {}),
|
|
178
184
|
});
|
|
179
185
|
if (outage) {
|
|
@@ -535,9 +541,13 @@ class NovelTargetHandler {
|
|
|
535
541
|
if (endpoint) {
|
|
536
542
|
errorMessage = `${errorMessage} [URL: ${endpoint}]`;
|
|
537
543
|
}
|
|
544
|
+
const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
|
|
545
|
+
(0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)({ type: 'novel' }, this.execution, { message: errorMessage, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
|
|
538
546
|
logger_1.logger.error(message, {
|
|
539
547
|
error: errorMessage,
|
|
540
548
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
549
|
+
error_type: cls.error_type,
|
|
550
|
+
retryable: cls.retryable,
|
|
541
551
|
stack: error instanceof Error ? error.stack : undefined,
|
|
542
552
|
});
|
|
543
553
|
}
|
|
@@ -112,5 +112,16 @@ export interface IDatabase {
|
|
|
112
112
|
* Update file path in database
|
|
113
113
|
*/
|
|
114
114
|
updateFilePath(pixivId: string, type: 'illustration' | 'novel', oldPath: string, newPath: string): number;
|
|
115
|
+
/**
|
|
116
|
+
* Durable system-error ledger (observability; never throws).
|
|
117
|
+
*/
|
|
118
|
+
readonly systemErrors: {
|
|
119
|
+
record(input: import('../observability/types').SystemErrorInput): void;
|
|
120
|
+
list(opts?: Record<string, unknown>): import('../observability/types').SystemErrorRow[];
|
|
121
|
+
markResolved(id: number): {
|
|
122
|
+
changes: number;
|
|
123
|
+
};
|
|
124
|
+
countSince(botId: string | null, hours: number): number;
|
|
125
|
+
};
|
|
115
126
|
}
|
|
116
127
|
//# sourceMappingURL=IDatabase.d.ts.map
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export type LogFormat = 'text' | 'json';
|
|
3
|
+
export interface LogContext {
|
|
4
|
+
service?: string;
|
|
5
|
+
component?: string;
|
|
6
|
+
bot_id?: string;
|
|
7
|
+
schedule_id?: string;
|
|
8
|
+
slot_id?: string;
|
|
9
|
+
pixiv_id?: string;
|
|
10
|
+
stage?: string;
|
|
11
|
+
trace_id?: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
2
14
|
interface LogMeta {
|
|
3
15
|
[key: string]: unknown;
|
|
4
16
|
}
|
|
5
17
|
declare class Logger {
|
|
6
18
|
private static readonly levelOrder;
|
|
7
19
|
private threshold;
|
|
20
|
+
private format;
|
|
8
21
|
private logPath;
|
|
9
22
|
setLevel(level: LogLevel): void;
|
|
23
|
+
setFormat(format: LogFormat): void;
|
|
24
|
+
/** Run `fn` with a log context that is merged into every log line emitted inside. */
|
|
25
|
+
runWithContext<T>(ctx: LogContext, fn: () => T): T;
|
|
10
26
|
setLogPath(path: string): void;
|
|
11
27
|
debug(message: string, meta?: LogMeta): void;
|
|
12
28
|
info(message: string, meta?: LogMeta): void;
|
|
13
29
|
warn(message: string, meta?: LogMeta): void;
|
|
14
30
|
error(message: string, meta?: LogMeta): void;
|
|
15
31
|
private write;
|
|
32
|
+
/**
|
|
33
|
+
* Rotate the current log file when it exceeds the size cap:
|
|
34
|
+
* rename to <name>-<ts>.log.gz, then prune archived logs older than
|
|
35
|
+
* retention days. Run inside write (low-traffic scheduler, acceptable;
|
|
36
|
+
* use a background job if this ever becomes a high-rate service).
|
|
37
|
+
*/
|
|
38
|
+
private rotateIfNeeded;
|
|
39
|
+
private pruneArchives;
|
|
16
40
|
}
|
|
17
41
|
export declare const logger: Logger;
|
|
18
42
|
export {};
|
package/dist/logger.js
CHANGED
|
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.logger = void 0;
|
|
4
4
|
const fs_1 = require("fs");
|
|
5
5
|
const path_1 = require("path");
|
|
6
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
7
|
+
const node_zlib_1 = require("node:zlib");
|
|
8
|
+
const ctxStore = new node_async_hooks_1.AsyncLocalStorage();
|
|
9
|
+
function defaultMaxBytes() {
|
|
10
|
+
const parsed = Number.parseInt(process.env.PIXIV_LOG_MAX_BYTES ?? '', 10);
|
|
11
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 * 1024 * 1024;
|
|
12
|
+
}
|
|
13
|
+
function retentionDays() {
|
|
14
|
+
const parsed = Number.parseInt(process.env.PIXIV_LOG_RETENTION_DAYS ?? '', 10);
|
|
15
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
|
|
16
|
+
}
|
|
6
17
|
class Logger {
|
|
7
18
|
static levelOrder = {
|
|
8
19
|
debug: 10,
|
|
@@ -11,13 +22,20 @@ class Logger {
|
|
|
11
22
|
error: 40,
|
|
12
23
|
};
|
|
13
24
|
threshold = 'info';
|
|
25
|
+
format = process.env.PIXIV_LOG_FORMAT === 'json' ? 'json' : 'text';
|
|
14
26
|
logPath = null;
|
|
15
27
|
setLevel(level) {
|
|
16
28
|
this.threshold = level;
|
|
17
29
|
}
|
|
30
|
+
setFormat(format) {
|
|
31
|
+
this.format = format;
|
|
32
|
+
}
|
|
33
|
+
/** Run `fn` with a log context that is merged into every log line emitted inside. */
|
|
34
|
+
runWithContext(ctx, fn) {
|
|
35
|
+
return ctxStore.run(ctx, fn);
|
|
36
|
+
}
|
|
18
37
|
setLogPath(path) {
|
|
19
38
|
this.logPath = path;
|
|
20
|
-
// Ensure log directory exists
|
|
21
39
|
const logDir = (0, path_1.join)(path, '..');
|
|
22
40
|
if (!(0, fs_1.existsSync)(logDir)) {
|
|
23
41
|
(0, fs_1.mkdirSync)(logDir, { recursive: true });
|
|
@@ -36,13 +54,19 @@ class Logger {
|
|
|
36
54
|
this.write('error', message, meta);
|
|
37
55
|
}
|
|
38
56
|
write(level, message, meta) {
|
|
39
|
-
if (Logger.levelOrder[level] < Logger.levelOrder[this.threshold])
|
|
57
|
+
if (Logger.levelOrder[level] < Logger.levelOrder[this.threshold])
|
|
40
58
|
return;
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
const ctx = ctxStore.getStore();
|
|
60
|
+
const record = {
|
|
61
|
+
timestamp: new Date().toISOString(),
|
|
62
|
+
level,
|
|
63
|
+
message,
|
|
64
|
+
...(ctx ?? {}),
|
|
65
|
+
...(meta ?? {}),
|
|
66
|
+
};
|
|
67
|
+
const logLine = this.format === 'json'
|
|
68
|
+
? JSON.stringify(record)
|
|
69
|
+
: `[${record.timestamp}] [${level.toUpperCase()}] ${message}${meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''}`;
|
|
46
70
|
switch (level) {
|
|
47
71
|
case 'debug':
|
|
48
72
|
console.debug(logLine);
|
|
@@ -56,20 +80,73 @@ class Logger {
|
|
|
56
80
|
case 'error':
|
|
57
81
|
console.error(logLine);
|
|
58
82
|
break;
|
|
59
|
-
default:
|
|
60
|
-
console.log(logLine);
|
|
83
|
+
default: console.log(logLine);
|
|
61
84
|
}
|
|
62
|
-
// Write to file if log path is set
|
|
63
85
|
if (this.logPath) {
|
|
64
86
|
try {
|
|
87
|
+
this.rotateIfNeeded();
|
|
65
88
|
(0, fs_1.appendFileSync)(this.logPath, logLine + '\n', 'utf-8');
|
|
66
89
|
}
|
|
67
90
|
catch (error) {
|
|
68
|
-
// Silently fail if file write fails to avoid breaking the application
|
|
69
91
|
console.error('Failed to write to log file', error);
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Rotate the current log file when it exceeds the size cap:
|
|
97
|
+
* rename to <name>-<ts>.log.gz, then prune archived logs older than
|
|
98
|
+
* retention days. Run inside write (low-traffic scheduler, acceptable;
|
|
99
|
+
* use a background job if this ever becomes a high-rate service).
|
|
100
|
+
*/
|
|
101
|
+
rotateIfNeeded() {
|
|
102
|
+
if (!this.logPath)
|
|
103
|
+
return;
|
|
104
|
+
let size = 0;
|
|
105
|
+
try {
|
|
106
|
+
if ((0, fs_1.existsSync)(this.logPath))
|
|
107
|
+
size = (0, fs_1.statSync)(this.logPath).size;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (size < defaultMaxBytes())
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
const dir = (0, path_1.join)(this.logPath, '..');
|
|
116
|
+
const base = (0, path_1.basename)(this.logPath);
|
|
117
|
+
const name = base.replace(/\.log$/, '');
|
|
118
|
+
const stamped = (0, path_1.join)(dir, `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
|
|
119
|
+
(0, fs_1.renameSync)(this.logPath, stamped);
|
|
120
|
+
(0, fs_1.writeFileSync)(`${stamped}.gz`, (0, node_zlib_1.gzipSync)((0, fs_1.readFileSync)(stamped)));
|
|
121
|
+
(0, fs_1.unlinkSync)(stamped);
|
|
122
|
+
this.pruneArchives(dir, `${name}-`, retentionDays());
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error('Failed to rotate log file', error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
pruneArchives(dir, prefix, days) {
|
|
129
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = (0, fs_1.readdirSync)(dir);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.startsWith(prefix) || !entry.endsWith('.log.gz'))
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
const p = (0, path_1.join)(dir, entry);
|
|
142
|
+
if ((0, fs_1.statSync)(p).mtimeMs < cutoff)
|
|
143
|
+
(0, fs_1.unlinkSync)(p);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// best effort
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
73
150
|
}
|
|
74
151
|
exports.logger = new Logger();
|
|
75
152
|
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { SystemErrorClassification } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Map an error (message / HTTP status / constructor name) to a stable
|
|
4
|
+
* download/system error class and whether it is safe to retry.
|
|
5
|
+
*/
|
|
6
|
+
export declare function classifySystemError(error: unknown, httpStatus?: number | null, stage?: string, messageHint?: string): SystemErrorClassification;
|
|
7
|
+
//# sourceMappingURL=classify.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifySystemError = classifySystemError;
|
|
4
|
+
const KNOWN_SUBTYPES = {
|
|
5
|
+
pixiv_network_5xx: 'NETWORK_TIMEOUT',
|
|
6
|
+
pixiv_network_4xx: 'NETWORK_TIMEOUT',
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Map an error (message / HTTP status / constructor name) to a stable
|
|
10
|
+
* download/system error class and whether it is safe to retry.
|
|
11
|
+
*/
|
|
12
|
+
function classifySystemError(error, httpStatus, stage, messageHint) {
|
|
13
|
+
const message = messageHint ?? (error instanceof Error ? error.message : String(error ?? ''));
|
|
14
|
+
const name = error instanceof Error ? error.constructor.name : typeof error;
|
|
15
|
+
const status = typeof httpStatus === 'number' ? httpStatus : null;
|
|
16
|
+
if (name === 'ConfigError' || /does not configure|unsupported .*target|invalid config/i.test(message)) {
|
|
17
|
+
return { error_type: 'CONFIG_ERROR', retryable: false };
|
|
18
|
+
}
|
|
19
|
+
if (status === 429 || /429|rate.?limit|cooldown/i.test(message)) {
|
|
20
|
+
return { error_type: 'PIXIV_RATE_LIMITED', retryable: true };
|
|
21
|
+
}
|
|
22
|
+
if (status === 401 || status === 403 || /login|auth|token|forbidden|invalid.*refresh|expired.*token/i.test(message)) {
|
|
23
|
+
const cdn = /cdn|i\.pximg|img\.pixiv/i.test(message);
|
|
24
|
+
return cdn
|
|
25
|
+
? { error_type: 'PIXIV_CDN_FORBIDDEN', retryable: false }
|
|
26
|
+
: { error_type: 'PIXIV_AUTH_FAILED', retryable: status === 401 };
|
|
27
|
+
}
|
|
28
|
+
if (status === 404 || /404|not found|does not exist/i.test(message)) {
|
|
29
|
+
return { error_type: 'PIXIV_NOT_FOUND', retryable: false };
|
|
30
|
+
}
|
|
31
|
+
if (/timeout|timed out|abort|aborted|econn|enotfound|etimedout|network|socket|refused|unavailable|ECONN/i.test(message) || name === 'AbortError') {
|
|
32
|
+
return { error_type: 'NETWORK_TIMEOUT', retryable: true };
|
|
33
|
+
}
|
|
34
|
+
if (/corrupt|invalid image|unexpected eof|truncated/i.test(message)) {
|
|
35
|
+
return { error_type: 'DOWNLOAD_CORRUPTED', retryable: true };
|
|
36
|
+
}
|
|
37
|
+
if (stage === 'image_process' || /convert|processing|\bimage process/i.test(message)) {
|
|
38
|
+
return { error_type: 'IMAGE_PROCESS_FAILED', retryable: stage === 'image_process' };
|
|
39
|
+
}
|
|
40
|
+
if (/telegram|sendMessage|upload/i.test(message)) {
|
|
41
|
+
return { error_type: 'TELEGRAM_UPLOAD_FAILED', retryable: false };
|
|
42
|
+
}
|
|
43
|
+
if (typeof status === 'number' && status >= 500) {
|
|
44
|
+
return { error_type: 'NETWORK_TIMEOUT', retryable: true };
|
|
45
|
+
}
|
|
46
|
+
if (name in KNOWN_SUBTYPES) {
|
|
47
|
+
return { error_type: KNOWN_SUBTYPES[name], retryable: true };
|
|
48
|
+
}
|
|
49
|
+
return { error_type: 'INTERNAL_ERROR', retryable: true };
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=classify.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TargetConfig } from '../config';
|
|
2
|
+
import { TargetExecutionContext } from '../scheduler/WorkIdentity';
|
|
3
|
+
import { SystemErrorInput } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* Build the standard structured error fields for a target-cell handler from
|
|
6
|
+
* the cell identity plus the current stage/error. Nothing here throws.
|
|
7
|
+
* bot_id / schedule_id derive from the slot identity (`bot1-daily@...`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function targetErrorContext(target: TargetConfig, execution: TargetExecutionContext | null, extra: Partial<SystemErrorInput>): SystemErrorInput;
|
|
10
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.targetErrorContext = targetErrorContext;
|
|
4
|
+
/**
|
|
5
|
+
* Build the standard structured error fields for a target-cell handler from
|
|
6
|
+
* the cell identity plus the current stage/error. Nothing here throws.
|
|
7
|
+
* bot_id / schedule_id derive from the slot identity (`bot1-daily@...`).
|
|
8
|
+
*/
|
|
9
|
+
function targetErrorContext(target, execution, extra) {
|
|
10
|
+
const slotId = execution?.slotId;
|
|
11
|
+
const scheduleId = slotId ? slotId.split('@')[0] : undefined;
|
|
12
|
+
const botId = scheduleId ? scheduleId.split('-')[0] : undefined;
|
|
13
|
+
return {
|
|
14
|
+
service: 'pixivflow',
|
|
15
|
+
component: target.type === 'novel' ? 'novel_target' : 'illustration_target',
|
|
16
|
+
schedule_id: scheduleId,
|
|
17
|
+
bot_id: botId,
|
|
18
|
+
slot_id: slotId,
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { IDatabase } from '../interfaces/IDatabase';
|
|
2
|
+
import { SystemErrorClassification, SystemErrorInput } from './types';
|
|
3
|
+
export { classifySystemError } from './classify';
|
|
4
|
+
export * from './types';
|
|
5
|
+
/** Record a structured system error with taxonomy; never throws. */
|
|
6
|
+
export declare function recordSystemError(database: Pick<IDatabase, 'systemErrors'>, input: SystemErrorInput, classified?: SystemErrorClassification | null): void;
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.classifySystemError = void 0;
|
|
18
|
+
exports.recordSystemError = recordSystemError;
|
|
19
|
+
const logger_1 = require("../logger");
|
|
20
|
+
const classify_1 = require("./classify");
|
|
21
|
+
var classify_2 = require("./classify");
|
|
22
|
+
Object.defineProperty(exports, "classifySystemError", { enumerable: true, get: function () { return classify_2.classifySystemError; } });
|
|
23
|
+
__exportStar(require("./types"), exports);
|
|
24
|
+
/** Record a structured system error with taxonomy; never throws. */
|
|
25
|
+
function recordSystemError(database, input, classified = null) {
|
|
26
|
+
try {
|
|
27
|
+
const cls = classified ?? (0, classify_1.classifySystemError)(new Error(input.message), input.http_status ?? null, input.stage);
|
|
28
|
+
database.systemErrors.record({ ...input, ...cls });
|
|
29
|
+
logger_1.logger.error(`system_error recorded`, { error_type: cls.error_type, retryable: cls.retryable, ...input });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// Observability must never break the business path.
|
|
33
|
+
console.error('Failed to persist system error', error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SystemErrorType = 'PIXIV_AUTH_FAILED' | 'PIXIV_RATE_LIMITED' | 'PIXIV_NOT_FOUND' | 'PIXIV_CDN_FORBIDDEN' | 'NETWORK_TIMEOUT' | 'DOWNLOAD_CORRUPTED' | 'IMAGE_PROCESS_FAILED' | 'TELEGRAM_UPLOAD_FAILED' | 'CONFIG_ERROR' | 'INTERNAL_ERROR';
|
|
2
|
+
export interface SystemErrorClassification {
|
|
3
|
+
error_type: SystemErrorType;
|
|
4
|
+
retryable: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface SystemErrorInput extends Partial<SystemErrorClassification> {
|
|
7
|
+
service?: string;
|
|
8
|
+
component?: string;
|
|
9
|
+
bot_id?: string;
|
|
10
|
+
schedule_id?: string;
|
|
11
|
+
slot_id?: string;
|
|
12
|
+
pixiv_id?: string;
|
|
13
|
+
stage?: string;
|
|
14
|
+
message: string;
|
|
15
|
+
http_status?: number | null;
|
|
16
|
+
trace_id?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SystemErrorRow extends SystemErrorInput {
|
|
19
|
+
id: number;
|
|
20
|
+
created_at: string;
|
|
21
|
+
resolved_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=types.d.ts.map
|