Мониторинг: логи, ошибки и запись экрана портала заявок (@tnsu/monitoring-web) (#29)
Tests / frontend-test (push) Successful in 1m24s
Tests / frontend-test (push) Successful in 1m24s
Логи, ошибки и запись экрана портала заявок в центральный мониторинг (monitoring.tnsu.kz).
**Что поменялось**
- `@tnsu/monitoring-web` 0.3.1 из реестра tansu.
- `RequestPortalWebPart.onInit`: `initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) })` — до `installErrorLogging`. Один на страницу (экземпляров веб-части бывает несколько) — это делает сама библиотека.
- `config/package-solution.json`: запрос `Logs.Write` к регистрации мониторинга `9232d9e2-…` (рядом с уже запрошенным `Reports.Read` той же регистрации).
- Хэш коммита сборки (`git.commit` в логах, «Сборка» в плеере): `config/webpack-patch.json` ссылается на патч из пакета (`node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs`), он подставляет `__TNSU_GIT_COMMIT__` из `GIT_COMMIT`, `GITHUB_SHA` или `git rev-parse`. Пайплайн менять не нужно.
**Что пишется**
- Необработанные ошибки и отказы промисов, `console.error` (в том числе вывод `installErrorLogging`), `log.*`.
- Запись экрана (rrweb) — **ничего не маскируется**, записывается вся страница SharePoint, не только веб-часть. Неудачные запросы — вкладка «Сеть» в плеере.
- В workbench / при `gulp serve` окружение `development` — в админке мониторинга такие записи по умолчанию скрыты.
- Нет связи с мониторингом час — тост «Ошибка №… Обратитесь к администратору» (не чаще 3 раз в сутки).
**Проверено:** продакшен-сборка (`heft build --production`) проходит, патч применяется, хэш коммита попадает в бандл веб-части.
**После выкатки .sppkg** администратор один раз одобряет запрос: SharePoint admin center → Advanced → API access → `Logs.Write` → Approve. Пока не одобрено, токена нет и данные копятся в браузере (до 50 МБ), портал работает как обычно.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||
import PnPTelemetry from '@pnp/telemetry-js';
|
||||
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
|
||||
|
||||
import * as strings from 'RequestPortalWebPartStrings';
|
||||
import RequestPortal from './components/RequestPortal';
|
||||
@@ -98,7 +100,14 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
}
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
// Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через
|
||||
// AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development.
|
||||
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
|
||||
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
|
||||
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
|
||||
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
|
||||
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
|
||||
PnPTelemetry.getInstance().optOut();
|
||||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||||
|
||||
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log } from '@tnsu/monitoring-web';
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
@@ -25,6 +26,14 @@ export interface IPendingRow extends IApprovalRow {
|
||||
isDelegator: boolean;
|
||||
}
|
||||
|
||||
// Решение → текст события в логах мониторинга.
|
||||
const DECISION_MESSAGE: Record<ApproverDecision, string> = {
|
||||
approve: 'Заявка согласована',
|
||||
return: 'Заявка возвращена на доработку',
|
||||
reject: 'Заявка отклонена',
|
||||
execute: 'Заявка выполнена исполнителем',
|
||||
};
|
||||
|
||||
// Решение → статус его строки в списке согласования.
|
||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
approve: REQUEST_STATUS.approved,
|
||||
@@ -336,6 +345,7 @@ export class ApproverService {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const requestStatus = this.computeRequestStatus(rows);
|
||||
await this.updateRequestStatus(requestId, requestStatus);
|
||||
await this.logDecision(requestId, rowId, decision, comment, requestStatus);
|
||||
emitRequestsChanged();
|
||||
return requestStatus;
|
||||
}
|
||||
@@ -364,6 +374,42 @@ export class ApproverService {
|
||||
return REQUEST_STATUS.execution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение — в логи мониторинга, с кодом и названием заявки, чтобы её было видно прямо в списке логов.
|
||||
* Код и название дочитываются отдельно; не получилось — пишем без них: решение уже применено.
|
||||
*/
|
||||
private async logDecision(
|
||||
requestId: number,
|
||||
rowId: number,
|
||||
decision: ApproverDecision,
|
||||
comment: string,
|
||||
requestStatus: string
|
||||
): Promise<void> {
|
||||
let code = '';
|
||||
let title = '';
|
||||
try {
|
||||
const item = (await this.dataList().items.getById(requestId).select('Title', 'RequestUniqueCode')()) as {
|
||||
Title?: string;
|
||||
RequestUniqueCode?: string;
|
||||
};
|
||||
code = item.RequestUniqueCode || '';
|
||||
title = item.Title || '';
|
||||
} catch {
|
||||
// без кода и названия — лог всё равно нужен
|
||||
}
|
||||
const what = [code, title].filter(Boolean).join(' · ') || `№${requestId}`;
|
||||
log.info(`${DECISION_MESSAGE[decision]}: ${what}`, {
|
||||
'request.id': requestId,
|
||||
'request.code': code,
|
||||
'request.title': title,
|
||||
'request.list': this.dataListRef,
|
||||
'request.status': requestStatus,
|
||||
'approval.decision': decision,
|
||||
'approval.rowId': rowId,
|
||||
'approval.comment': comment.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
private dataList(): IList {
|
||||
return this.dataListRef.startsWith('/')
|
||||
? getSP().web.getList(this.dataListRef)
|
||||
|
||||
@@ -2,10 +2,13 @@ import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import { log } from '@tnsu/monitoring-web';
|
||||
import { getSP } from './pnp/pnpjs';
|
||||
import { buildRequestUniqueCode } from './requestCode';
|
||||
import { dataListTitle } from './listTitles';
|
||||
import { RequestTypeId } from '../models/types';
|
||||
import { REQUEST_TYPES } from '../data/requests';
|
||||
import { isSubmittedStatus } from './approval/models/statuses';
|
||||
|
||||
/**
|
||||
* Список данных, в который фактически пишем: listId (конкретная партиция —
|
||||
@@ -50,6 +53,7 @@ export async function saveRequestItem(
|
||||
|
||||
if (existingId) {
|
||||
await list.items.getById(existingId).update(item);
|
||||
logSaved(formId, existingId, undefined, item, false);
|
||||
return { id: existingId };
|
||||
}
|
||||
|
||||
@@ -58,5 +62,34 @@ export async function saveRequestItem(
|
||||
// Код известен только после создания элемента (нужен его Id).
|
||||
const code = buildRequestUniqueCode(codePrefix, id, new Date());
|
||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||
logSaved(formId, id, code, item, true);
|
||||
return { id, code };
|
||||
}
|
||||
|
||||
/**
|
||||
* События для мониторинга: какая заявка создана и отправлена на согласование (пользователь — из токена).
|
||||
* В тексте — вид, код и название заявки, чтобы её было видно прямо в списке логов. Решения согласующих
|
||||
* пишет approverDecision.service.ts.
|
||||
*/
|
||||
function logSaved(
|
||||
formId: RequestTypeId,
|
||||
id: number,
|
||||
code: string | undefined,
|
||||
item: Record<string, unknown>,
|
||||
created: boolean
|
||||
): void {
|
||||
const typeTitle = REQUEST_TYPES.find((t) => t.id === formId)?.title ?? formId;
|
||||
const title = typeof item.Title === 'string' ? item.Title : '';
|
||||
const status = typeof item.RequestStatus === 'string' ? item.RequestStatus : '';
|
||||
const what = [typeTitle, code, title].filter(Boolean).join(' · ');
|
||||
const attributes = {
|
||||
'request.type': formId,
|
||||
'request.typeTitle': typeTitle,
|
||||
'request.id': id,
|
||||
'request.code': code ?? '',
|
||||
'request.title': title,
|
||||
'request.status': status,
|
||||
};
|
||||
if (created) log.info(`Создана заявка: ${what}`, attributes);
|
||||
if (isSubmittedStatus(status)) log.info(`Заявка отправлена на согласование: ${what}`, attributes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user