1 Commits

Author SHA1 Message Date
gorbenko.pavel e34d290865 Мониторинг: логи, ошибки и запись экрана портала заявок (@tnsu/monitoring-web) (#29)
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
2026-10-30 14:24:58 +00:00
6 changed files with 80 additions and 28 deletions
+5 -4
View File
@@ -17,7 +17,8 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/monitoring-web": "^0.3.3", "@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
@@ -9909,9 +9910,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/monitoring-web": { "node_modules/@tnsu/monitoring-web": {
"version": "0.3.3", "version": "0.3.4",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.3/monitoring-web-0.3.3.tgz", "resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
"integrity": "sha512-jxuGTHA8pI71HZzAl+pDhSKgMY7YA/F8Xiq0U4koTw2w7oWcF8XeRwQ3z6lJUpvpWGn1FLpZOjE9/8SoMBPNrA==", "integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@rrweb/rrweb-plugin-console-record": "^2.2.0", "@rrweb/rrweb-plugin-console-record": "^2.2.0",
+2 -1
View File
@@ -21,7 +21,8 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/monitoring-web": "^0.3.3", "@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
@@ -9,6 +9,7 @@ import {
} from '@microsoft/sp-property-pane'; } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base';
import PnPTelemetry from '@pnp/telemetry-js';
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web'; import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
import * as strings from 'RequestPortalWebPartStrings'; import * as strings from 'RequestPortalWebPartStrings';
@@ -104,6 +105,9 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама. // Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) }); initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
PnPTelemetry.getInstance().optOut();
initSP(this.context); // единый клиент PnPjs для записи в списки initSP(this.context); // единый клиент PnPjs для записи в списки
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они // Адрес approval-route должен быть выставлен ДО загрузки партиций — они
@@ -1,5 +1,4 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { log } from '@tnsu/monitoring-web';
import { resolveRoute } from './approvalRoute.service'; import { resolveRoute } from './approvalRoute.service';
import { ApprovalListService, IApproverToWrite } from './approvalList.service'; import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { IMatrixRow } from './models/IMatrixRow'; import { IMatrixRow } from './models/IMatrixRow';
@@ -69,11 +68,6 @@ export async function writeApprovers(
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1); await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
if (submitted) { if (submitted) {
await approval.activateNextQueued(requestId); await approval.activateNextQueued(requestId);
log.info('Заявка отправлена на согласование', {
'request.id': requestId,
'request.status': status,
'route.approvers': approvers.length,
});
} }
} }
@@ -144,11 +138,5 @@ export async function writeRoute(
if (submitted) { if (submitted) {
await approval.activateNextQueued(requestId); await approval.activateNextQueued(requestId);
log.info('Заявка отправлена на согласование', {
'request.id': requestId,
'request.status': status,
'route.approvers': approvers.length,
'route.executors': executors.length,
});
} }
} }
@@ -345,14 +345,7 @@ export class ApproverService {
const rows = await this.approval.getForRequest(requestId); const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows); const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus); await this.updateRequestStatus(requestId, requestStatus);
log.info(DECISION_MESSAGE[decision], { await this.logDecision(requestId, rowId, decision, comment, requestStatus);
'request.id': requestId,
'request.list': this.dataListRef,
'request.status': requestStatus,
'approval.decision': decision,
'approval.rowId': rowId,
'approval.comment': comment.slice(0, 500),
});
emitRequestsChanged(); emitRequestsChanged();
return requestStatus; return requestStatus;
} }
@@ -381,6 +374,42 @@ export class ApproverService {
return REQUEST_STATUS.execution; 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 { private dataList(): IList {
return this.dataListRef.startsWith('/') return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef) ? getSP().web.getList(this.dataListRef)
+32 -3
View File
@@ -7,6 +7,8 @@ import { getSP } from './pnp/pnpjs';
import { buildRequestUniqueCode } from './requestCode'; import { buildRequestUniqueCode } from './requestCode';
import { dataListTitle } from './listTitles'; import { dataListTitle } from './listTitles';
import { RequestTypeId } from '../models/types'; import { RequestTypeId } from '../models/types';
import { REQUEST_TYPES } from '../data/requests';
import { isSubmittedStatus } from './approval/models/statuses';
/** /**
* Список данных, в который фактически пишем: listId (конкретная партиция — * Список данных, в который фактически пишем: listId (конкретная партиция —
@@ -51,6 +53,7 @@ export async function saveRequestItem(
if (existingId) { if (existingId) {
await list.items.getById(existingId).update(item); await list.items.getById(existingId).update(item);
logSaved(formId, existingId, undefined, item, false);
return { id: existingId }; return { id: existingId };
} }
@@ -59,8 +62,34 @@ export async function saveRequestItem(
// Код известен только после создания элемента (нужен его Id). // Код известен только после создания элемента (нужен его Id).
const code = buildRequestUniqueCode(codePrefix, id, new Date()); const code = buildRequestUniqueCode(codePrefix, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code }); await list.items.getById(id).update({ RequestUniqueCode: code });
// Событие для мониторинга: кто (пользователь — из токена) и какую заявку создал. Отправка на согласование logSaved(formId, id, code, item, true);
// и решения пишутся отдельно — approvalSubmission.service.ts и approverDecision.service.ts.
log.info('Создана заявка', { 'request.type': formId, 'request.id': id, 'request.code': code });
return { id, code }; 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);
}