poll-error-logs.js 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. #!/usr/bin/env node
  2. import fsp from 'node:fs/promises';
  3. import path from 'node:path';
  4. import { execFile } from 'node:child_process';
  5. const apiUrl = process.env.ERROR_LOG_API_URL || 'http://127.0.0.1:3050/internal/error-logs';
  6. const token = process.env.ERROR_LOG_API_TOKEN || process.env.INTERNAL_API_TOKEN || '';
  7. const stateFile = process.env.ERROR_LOG_POLL_STATE_FILE || path.join(process.cwd(), '.error-log-poll-state.json');
  8. const pollIntervalMs = Number(process.env.ERROR_LOG_POLL_INTERVAL_MS || 60000);
  9. const limit = Number(process.env.ERROR_LOG_POLL_LIMIT || 50);
  10. const enableNotify = process.env.ERROR_LOG_NOTIFY === 'true';
  11. const startFromBeginning = process.env.ERROR_LOG_START_FROM_BEGINNING === 'true';
  12. async function loadState() {
  13. try {
  14. return {
  15. isNew: false,
  16. state: JSON.parse(await fsp.readFile(stateFile, 'utf8')),
  17. };
  18. } catch (error) {
  19. return {
  20. isNew: true,
  21. state: {
  22. sinceId: process.env.ERROR_LOG_SINCE_ID === undefined ? null : Number(process.env.ERROR_LOG_SINCE_ID || 0),
  23. },
  24. };
  25. }
  26. }
  27. async function saveState(state) {
  28. await fsp.writeFile(stateFile, JSON.stringify(state, null, 2));
  29. }
  30. function formatLog(item) {
  31. const location = item.FilePath ? `${item.FilePath}:${item.LineNumber || 0}:${item.ColumnNumber || 0}` : 'unknown location';
  32. const request = item.RequestUrl ? ` ${item.RequestMethod || 'REQUEST'} ${item.RequestUrl}` : '';
  33. return [
  34. `[${item.ID}] ${item.Source} ${item.Status} x${item.Count}`,
  35. item.Message,
  36. location,
  37. request.trim(),
  38. ].filter(Boolean).join('\n');
  39. }
  40. function notify(item) {
  41. if (!enableNotify || process.platform !== 'darwin') {
  42. return;
  43. }
  44. const title = `New ${item.Source} error #${item.ID}`;
  45. const message = String(item.Message || '').replace(/"/g, '\\"').slice(0, 180);
  46. execFile('osascript', [
  47. '-e',
  48. `display notification "${message}" with title "${title}"`,
  49. ]);
  50. }
  51. function buildApiUrl({ sinceId = 0, latest = false } = {}) {
  52. const url = new URL(apiUrl);
  53. if (latest) {
  54. url.pathname = url.pathname.replace(/\/$/, '') + '/latest';
  55. } else {
  56. url.searchParams.set('since_id', sinceId);
  57. }
  58. url.searchParams.set('status', 'all');
  59. url.searchParams.set('limit', limit);
  60. return url;
  61. }
  62. async function fetchErrorLogs(url) {
  63. if (!token) {
  64. throw new Error('ERROR_LOG_API_TOKEN or INTERNAL_API_TOKEN is required');
  65. }
  66. const response = await fetch(url, {
  67. headers: {
  68. 'x-internal-token': token,
  69. },
  70. });
  71. if (!response.ok) {
  72. throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  73. }
  74. const data = await response.json();
  75. if (data.errcode !== 10000) {
  76. throw new Error(data.errMsg || `Unexpected response: ${JSON.stringify(data)}`);
  77. }
  78. return data.result?.list || [];
  79. }
  80. async function fetchLatestSinceId() {
  81. const list = await fetchErrorLogs(buildApiUrl({ latest: true }));
  82. return list.reduce((maxId, item) => Math.max(maxId, Number(item.ID || 0)), 0);
  83. }
  84. async function pollOnce(state) {
  85. const list = await fetchErrorLogs(buildApiUrl({ sinceId: state.sinceId || 0 }));
  86. if (list.length === 0) {
  87. return;
  88. }
  89. for (const item of list) {
  90. console.log('\n' + formatLog(item));
  91. notify(item);
  92. state.sinceId = Math.max(Number(state.sinceId || 0), Number(item.ID || 0));
  93. }
  94. await saveState(state);
  95. }
  96. async function main() {
  97. const loaded = await loadState();
  98. const state = loaded.state;
  99. if (loaded.isNew && state.sinceId === null) {
  100. state.sinceId = startFromBeginning ? 0 : await fetchLatestSinceId();
  101. await saveState(state);
  102. }
  103. console.log(`[error-log-poller] polling ${apiUrl} every ${pollIntervalMs}ms from ID ${state.sinceId || 0}`);
  104. await pollOnce(state);
  105. setInterval(() => {
  106. pollOnce(state).catch((error) => {
  107. console.error('[error-log-poller] poll failed:', error.message || error);
  108. });
  109. }, pollIntervalMs);
  110. }
  111. main().catch((error) => {
  112. console.error('[error-log-poller] fatal:', error.message || error);
  113. process.exit(1);
  114. });