Phase 1: Push Delivery Reporter Service + Hook Points
참조 패턴:
AgentzProxyService.notifySessionEnd()(best-effort HTTP),ChannelAnalyticsService.recordPushSent()(fire-and-forget) 변경 파일: 1 NEW + 3 EDIT
변경 파일
| 파일 | 유형 | 설명 |
|---|---|---|
apps/dha-sleep-api/src/app/push/push-delivery-reporter.service.ts | NEW | Platform 보고 서비스 |
apps/dha-sleep-api/src/app/push/push-job.processor.ts | EDIT | COMPLETED/FAILED/CANCELLED 보고 hook |
apps/dha-sleep-api/src/app/push/push-scheduler.service.ts | EDIT | SCHEDULED 보고 hook |
apps/dha-sleep-api/src/app/push/push.module.ts | EDIT | Reporter 서비스 등록 |
1-1. Push Delivery Reporter Service [NEW]
파일: apps/dha-sleep-api/src/app/push/push-delivery-reporter.service.ts
역할: agentz-studio platform에 push delivery 상태를 보고하는 best-effort HTTP 서비스
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerService } from '@core/logging';
export interface PushDeliveryReportParams {
pushId: string;
status: 'SCHEDULED' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
timestamp: Date;
pushType?: string;
sessionId?: string | null;
userId?: string;
channel?: string;
error?: string;
retryCount?: number;
scheduledAt?: Date;
metadata?: Record<string, unknown>;
}
/**
* Push Delivery Reporter Service
*
* Reports push delivery status to agentz-studio platform.
* Best-effort: failure does not propagate to caller.
*
* Endpoint: POST {agentzApiUrl}/push-delivery/report
* Auth: x-api-key header (same key used for chat/invoke calls)
*
* @see AgentzProxyService.notifySessionEnd() — same best-effort HTTP pattern
* @see agentz-studio Plan 120 — Push Delivery Monitoring Platform
*/
@Injectable()
export class PushDeliveryReporterService {
private readonly enabled: boolean;
private readonly apiUrl: string;
private readonly apiKey: string;
constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.logger.setContext(PushDeliveryReporterService.name);
this.enabled = this.config.get<boolean>('agentz.enabled', false);
this.apiUrl = this.config.get<string>('agentz.apiUrl', '');
this.apiKey = this.config.get<string>('agentz.apiKey', '');
}
/**
* Report push delivery status to platform.
*
* Best-effort: catches all errors internally.
* Called as fire-and-forget from PushJobProcessor and PushSchedulerService.
*/
async report(params: PushDeliveryReportParams): Promise<void> {
if (!this.enabled || !this.apiUrl || !this.apiKey) {
this.logger.debug('Push delivery reporting disabled or not configured');
return;
}
const baseUrl = this.apiUrl.endsWith('/') ? this.apiUrl.slice(0, -1) : this.apiUrl;
const url = `${baseUrl}/push-delivery/report`;
const body = {
pushId: params.pushId,
status: params.status,
timestamp: params.timestamp.toISOString(),
...(params.pushType && { pushType: params.pushType }),
...(params.sessionId && { sessionId: params.sessionId }),
...(params.userId && { userId: params.userId }),
...(params.channel && { channel: params.channel }),
...(params.error && { error: params.error }),
...(params.retryCount !== undefined && { retryCount: params.retryCount }),
...(params.scheduledAt && { scheduledAt: params.scheduledAt.toISOString() }),
...(params.metadata && { metadata: params.metadata }),
};
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(5_000), // 5s timeout
});
if (response.ok) {
this.logger.debug('Push delivery reported', {
pushId: params.pushId,
status: params.status,
});
} else {
const responseBody = await response.text().catch(() => 'N/A');
this.logger.warn('Push delivery report failed', {
pushId: params.pushId,
status: params.status,
httpStatus: response.status,
body: responseBody,
});
}
} catch (error) {
// Best-effort: do not propagate failure
this.logger.warn('Push delivery report error (non-fatal)', {
pushId: params.pushId,
status: params.status,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
참조 패턴:
AgentzProxyService.notifySessionEnd()(lines 297-340) — 동일한 best-effort fetch + AbortSignal.timeout 패턴
1-2. PushJobProcessor — COMPLETED/FAILED/CANCELLED 보고 Hook [EDIT]
파일: apps/dha-sleep-api/src/app/push/push-job.processor.ts
역할: push 상태 변경 시점에 reporter 호출 추가
변경 1: Import + DI 추가
// 변경 전 (line 16):
import { ChannelAnalyticsService } from '../chat/services/channel-analytics.service';
// 변경 후:
import { ChannelAnalyticsService } from '../chat/services/channel-analytics.service';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 27-33):
constructor(
private readonly prisma: PrismaService,
private readonly notificationRouter: NotificationChannelRouterService,
private readonly channelAnalytics: ChannelAnalyticsService,
private readonly logger: LoggerService,
private readonly commandBus: CommandBus,
) {
// 변경 후:
constructor(
private readonly prisma: PrismaService,
private readonly notificationRouter: NotificationChannelRouterService,
private readonly channelAnalytics: ChannelAnalyticsService,
private readonly logger: LoggerService,
private readonly commandBus: CommandBus,
private readonly pushDeliveryReporter: PushDeliveryReporterService,
) {
변경 2: CANCELLED 보고 (user activity 취소 후)
// 변경 전 (line 85-89):
this.logger.info(`Push ${pushId} cancelled - user recently active within ${PUSH_CANCEL_QUIET_MINUTES}min`, {
userId,
scheduledAt: push.scheduledAt.toISOString(),
});
return;
// 변경 후:
this.logger.info(`Push ${pushId} cancelled - user recently active within ${PUSH_CANCEL_QUIET_MINUTES}min`, {
userId,
scheduledAt: push.scheduledAt.toISOString(),
});
// Report cancellation to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId,
status: 'CANCELLED',
timestamp: new Date(),
pushType: type,
sessionId: push.createdFromSessionId,
userId,
scheduledAt: push.scheduledAt,
}).catch(() => { /* best-effort */ });
return;
변경 3: COMPLETED 보고 (발송 성공 후)
// 변경 전 (line 130-137):
// Record push sent analytics (fire-and-forget)
this.channelAnalytics.recordPushSent({
userId,
pushId,
deliveryChannel: routeResult.channel,
pushType: type,
systemCurrentTime: sentAt,
});
// 변경 후:
// Record push sent analytics (fire-and-forget)
this.channelAnalytics.recordPushSent({
userId,
pushId,
deliveryChannel: routeResult.channel,
pushType: type,
systemCurrentTime: sentAt,
});
// Report delivery to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId,
status: 'COMPLETED',
timestamp: sentAt,
pushType: type,
sessionId: push.createdFromSessionId,
userId,
channel: routeResult.channel,
scheduledAt: push.scheduledAt,
metadata: routeResult.messageId
? { messageId: String(routeResult.messageId) }
: undefined,
}).catch(() => { /* best-effort */ });
변경 4: FAILED 보고 (handleSendPush 실패 분기)
주의: 기존 코드의 retry 동작 분석 결과,
handleFailedJob()의attemptsMade >= 3가드에 도달하는 것은 사실상 불가능하다. 이유: 첫 번째 실패 시 DB status가 FAILED로 변경(line 153)되고, retry attempt 2에서는push.status !== SCHEDULED체크(line 61)에 걸려 성공(return)으로 처리되기 때문이다. 따라서 FAILED 보고는handleSendPush()의 실패 분기(line 151-171)에서 수행해야 한다.
// 변경 전 (line 151-171):
} else {
// 6b. 실패 시 FAILED로 변경
await this.prisma.scheduledPush.update({
where: { id: pushId },
data: {
status: EngagementStatus.FAILED,
metadata: {
...(push.metadata as object || {}),
failureReason: routeResult.error,
failedChannel: routeResult.channel,
},
},
});
this.logger.error(`Push ${pushId} failed via ${routeResult.channel}: ${routeResult.error}`, undefined, {
userId,
channel: routeResult.channel,
});
// 재시도를 위해 에러 throw
throw new Error(routeResult.error || 'Push send failed');
}
// 변경 후:
} else {
// 6b. 실패 시 FAILED로 변경
await this.prisma.scheduledPush.update({
where: { id: pushId },
data: {
status: EngagementStatus.FAILED,
metadata: {
...(push.metadata as object || {}),
failureReason: routeResult.error,
failedChannel: routeResult.channel,
},
},
});
this.logger.error(`Push ${pushId} failed via ${routeResult.channel}: ${routeResult.error}`, undefined, {
userId,
channel: routeResult.channel,
});
// Report failure to platform (fire-and-forget)
// NOTE: 기존 코드에서 line 153의 status→FAILED + line 171의 throw로 인해
// Bull retry attempt 2에서는 line 61의 status 체크에서 "already processed"로 스킵된다.
// 따라서 이 지점이 FAILED 보고의 유일한 실행 기회이다.
this.pushDeliveryReporter.report({
pushId,
status: 'FAILED',
timestamp: new Date(),
pushType: type,
sessionId: push.createdFromSessionId,
userId,
channel: routeResult.channel,
error: routeResult.error,
scheduledAt: push.scheduledAt,
}).catch(() => { /* best-effort */ });
// 재시도를 위해 에러 throw
throw new Error(routeResult.error || 'Push send failed');
}
handleFailedJob()은 수정하지 않는다. 기존
attemptsMade >= 3가드의 동작 변경은 이 플랜의 범위 밖이다.
1-3. PushSchedulerService — SCHEDULED 보고 Hook [EDIT]
파일: apps/dha-sleep-api/src/app/push/push-scheduler.service.ts
역할: push 생성 시 SCHEDULED 상태 보고
변경 1: Import + DI 추가
// 변경 전 (line 7):
import { PushQueueService } from './push-queue.service';
// 변경 후:
import { PushQueueService } from './push-queue.service';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 41-49):
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly logger: LoggerService,
private readonly pushQueueService: PushQueueService,
private readonly queryBus: QueryBus,
@Inject(SCHEDULED_PUSH_REPOSITORY)
private readonly scheduledPushRepository: ScheduledPushRepository,
) {
// 변경 후:
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly logger: LoggerService,
private readonly pushQueueService: PushQueueService,
private readonly queryBus: QueryBus,
@Inject(SCHEDULED_PUSH_REPOSITORY)
private readonly scheduledPushRepository: ScheduledPushRepository,
private readonly pushDeliveryReporter: PushDeliveryReporterService,
) {
변경 2: Replace 취소 시 CANCELLED 보고 (schedulePushFromPlan 내)
근거: 대체된(old) push와 새(new) push는 다른 sessionId를 가진다 (각각 별도 대화에서 생성). Platform sweep은
sessionId + userId + pushTypenatural key로 correlation하므로, old push를 CANCELLED로 보고하지 않으면 OVERDUE(false positive)로 감지된다.existingPushselect에id,scheduledAt,createdFromSessionId는 있지만type은 없으므로 select에 추가.
// 변경 전 (line 106-115):
const existingPush = await this.prisma.scheduledPush.findFirst({
where: {
userId,
status: EngagementStatus.SCHEDULED,
scheduledAt: {
gte: new Date(scheduledAtDate.getTime() - windowMs),
lte: new Date(scheduledAtDate.getTime() + windowMs),
},
},
select: { id: true, scheduledAt: true, createdFromSessionId: true },
});
// 변경 후:
const existingPush = await this.prisma.scheduledPush.findFirst({
where: {
userId,
status: EngagementStatus.SCHEDULED,
scheduledAt: {
gte: new Date(scheduledAtDate.getTime() - windowMs),
lte: new Date(scheduledAtDate.getTime() + windowMs),
},
},
select: { id: true, scheduledAt: true, createdFromSessionId: true, type: true },
});
// 변경 전 (line 125-131):
this.logger.info('Replacing existing push with newer context', {
userId,
sessionId: params.sessionId,
cancelledPushId: existingPush.id,
cancelledSessionId: existingPush.createdFromSessionId,
newScheduledAt: pushPlan.scheduledAt,
});
// 변경 후:
this.logger.info('Replacing existing push with newer context', {
userId,
sessionId: params.sessionId,
cancelledPushId: existingPush.id,
cancelledSessionId: existingPush.createdFromSessionId,
newScheduledAt: pushPlan.scheduledAt,
});
// Report replace-cancellation to platform (fire-and-forget)
// old push의 sessionId로 보고해야 platform tracking과 매칭됨
this.pushDeliveryReporter.report({
pushId: existingPush.id,
status: 'CANCELLED',
timestamp: new Date(),
pushType: existingPush.type.toLowerCase(),
sessionId: existingPush.createdFromSessionId,
userId,
scheduledAt: existingPush.scheduledAt,
}).catch(() => { /* best-effort */ });
pushType 변환:
existingPush.type은 Prisma enum(EVENING_CHECKIN, SCREAMING_SNAKE_CASE). Platform correlation은 webhook 원본값(evening_checkin, lowercase)과 매칭하므로.toLowerCase()적용.
변경 3: SCHEDULED 보고 (saveScheduledPush 후)
// 변경 전 (line 313-318):
this.logger.debug('Push job registered to Bull', {
pushId: savedPush.id,
scheduledAt: plan.scheduledAt,
delayMs,
});
}
}
// 변경 후:
this.logger.debug('Push job registered to Bull', {
pushId: savedPush.id,
scheduledAt: plan.scheduledAt,
delayMs,
});
// Report SCHEDULED to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId: savedPush.id!,
status: 'SCHEDULED',
timestamp: new Date(),
pushType: plan.type,
sessionId: params.sessionId,
userId: params.userId,
scheduledAt: new Date(plan.scheduledAt),
}).catch(() => { /* best-effort */ });
}
}
1-4. Push Module — Reporter 서비스 등록 [EDIT]
파일: apps/dha-sleep-api/src/app/push/push.module.ts
변경 내용
// 변경 전 (line 13):
import { PushController } from './push.controller';
// 변경 후:
import { PushController } from './push.controller';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 46-55):
providers: [
PushSchedulerService,
PushQueueService,
PushJobProcessor,
ScheduledPushMapper,
{
provide: SCHEDULED_PUSH_REPOSITORY,
useClass: PrismaScheduledPushRepository,
},
],
exports: [PushSchedulerService, PushQueueService],
// 변경 후:
providers: [
PushSchedulerService,
PushQueueService,
PushJobProcessor,
PushDeliveryReporterService,
ScheduledPushMapper,
{
provide: SCHEDULED_PUSH_REPOSITORY,
useClass: PrismaScheduledPushRepository,
},
],
exports: [PushSchedulerService, PushQueueService],
API 요청/응답 명세
Request
POST {agentzApiUrl}/push-delivery/report
Headers:
Content-Type: application/json
x-api-key: {agentzApiKey}
Body:
{
"pushId": "376e4d2f-0c6b-4ff1-a14e-2d30f0d0172b", // ScheduledPush.id
"status": "COMPLETED", // SCHEDULED | COMPLETED | FAILED | CANCELLED
"timestamp": "2026-03-20T21:01:00.000Z", // 상태 변경 시점
"pushType": "evening_checkin", // plan.type (lowercase snake_case, webhook 원본과 동일)
"sessionId": "sess_abc123", // ScheduledPush.createdFromSessionId
"userId": "user_12345", // ScheduledPush.userId
"channel": "fcm", // 발송 채널 (COMPLETED 시)
"error": "FCM Token Missing", // 에러 메시지 (FAILED 시)
"retryCount": 3, // 재시도 횟수 (FAILED 시)
"scheduledAt": "2026-03-20T21:00:00.000Z", // 원래 예정 시간
"metadata": { "messageId": "fcm_msg_xxx" } // 추가 메타데이터
}
Response (참고 — platform Plan 120)
{
"id": "clxyz...",
"status": "COMPLETED",
"reportedAt": "2026-03-20T21:01:00.000Z"
}
보고 매핑 상세
SCHEDULED 보고
| 필드 | 소스 | 설명 |
|---|---|---|
pushId | savedPush.id | Client-side ScheduledPush UUID |
status | 'SCHEDULED' | 고정값 |
timestamp | new Date() | 보고 시점 |
pushType | plan.type | evening_checkin, bedtime_reminder 등 |
sessionId | params.sessionId | Webhook의 data.sessionId |
userId | params.userId | Webhook의 data.userId |
scheduledAt | new Date(plan.scheduledAt) | 발송 예정 시간 |
COMPLETED 보고
| 필드 | 소스 | 설명 |
|---|---|---|
pushId | job.data.pushId | Client-side ScheduledPush UUID |
status | 'COMPLETED' | 고정값 |
timestamp | sentAt | 실제 발송 시점 |
pushType | job.data.type | Push 타입 |
sessionId | push.createdFromSessionId | DB에서 조회 |
userId | job.data.userId | Job 데이터 |
channel | routeResult.channel | 'fcm', 'telegram', 'kakaotalk' |
scheduledAt | push.scheduledAt | 원래 예정 시간 |
metadata.messageId | routeResult.messageId | 채널별 메시지 ID |
CANCELLED 보고
| 필드 | 소스 | 설명 |
|---|---|---|
pushId | job.data.pushId | Client-side ScheduledPush UUID |
status | 'CANCELLED' | 고정값 |
timestamp | new Date() | 취소 시점 |
pushType | job.data.type | Push 타입 |
sessionId | push.createdFromSessionId | DB에서 조회 |
userId | job.data.userId | Job 데이터 |
scheduledAt | push.scheduledAt | 원래 예정 시간 |
FAILED 보고 (handleSendPush 실패 분기에서)
| 필드 | 소스 | 설명 |
|---|---|---|
pushId | pushId (job.data에서 추출, line 42) | Client-side ScheduledPush UUID |
status | 'FAILED' | 고정값 |
timestamp | new Date() | 실패 시점 |
pushType | type (job.data에서 추출, line 42) | Push 타입 (lowercase: evening_checkin) |
sessionId | push.createdFromSessionId | line 51의 findUnique로 이미 조회됨 (추가 DB 조회 불필요) |
userId | userId (job.data에서 추출, line 42) | Job 데이터 |
channel | routeResult.channel | 실패한 채널 |
error | routeResult.error | 채널 라우터가 반환한 에러 메시지 |
scheduledAt | push.scheduledAt | line 51의 findUnique로 이미 조회됨 |
Platform Tracking Correlation
Platform sweep은 sessionId + userId + pushType natural key로 PushDeliveryTracking과 PushDeliveryReport를 매칭한다.
Platform PushDeliveryTracking (webhook payload 파싱으로 생성)
sessionId: data.sessionId
userId: data.userId
pushType: data.pushPlan.type
Client PushDeliveryReport (이 서비스가 보고)
sessionId: ScheduledPush.createdFromSessionId ← webhook의 data.sessionId 저장
userId: ScheduledPush.userId ← webhook의 data.userId 저장
pushType: ScheduledPush.type ← webhook의 data.pushPlan.type 매핑
→ Natural key 일치 보장됨
검증 포인트
- 빌드 성공:
nx build dha-sleep-api(또는nx serve dha-sleep-api) - SCHEDULED 보고: Push 생성 시 platform API 호출 로그 확인
- COMPLETED 보고: Push 발송 성공 시 platform API 호출 로그 확인
- CANCELLED 보고: User activity 취소 시 platform API 호출 로그 확인
- FAILED 보고: Push 최종 실패 시 platform API 호출 로그 확인
- Best-effort: Platform API 다운 시 push 처리에 영향 없음 확인
- 비활성화:
agentz.enabled=false시 보고 스킵 확인