{}
RCA: Empty error object logged during floorplan tile upload
Overview#
What Happened#
2026-05-08 05:28:12 UTC에 cupixworks-any-floorplan-agent 서비스에서 floorplan 86241의 tile upload credentials 요청 시 API가 403 "Floorplan not found"를 반환했다. 에이전트가 약 3분간 tiling 작업을 수행하는 동안 해당 floorplan이 삭제/접근 불가 상태가 되었으며, 에러 객체가 {} (빈 객체)로 직렬화되어 로깅되었다.
Quick Facts#
| Field | Value |
|---|---|
| exception.message | {} (empty serialized Error object) |
| top_frame | floorplan-service.ts:107 |
| runtime | Node.js (TypeScript agent) |
| env | production, us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| hoban (team 1163) | 1 | Floorplan 86241의 tile 업로드 실패, 결과물 미생성 |
Timeline#
- 05:25:30Z — 에이전트가 floorplan 86241 PDF 변환 시작 (source: 35029.pdf, frame 0)
- 05:27:28Z — tileFloorplan 시작
- 05:28:12.864Z — tiling 완료, tile 디렉토리 저장 완료
- 05:28:12.904Z — tile_upload_credentials API 요청 → 403 Floorplan not found (warn 로그)
- 05:28:12.905Z — 에러
{}로깅 (Error 객체 직렬화 실패) - 05:28:12.905Z — cleanUpAnythingRelatedModel 실행
- 05:28:13Z — SQS 메시지 삭제, 처리 종료
Error Log#
{}
Impact#
- Service:
cupixworks-any-floorplan-agent - 발생 횟수: 1
- 최초 발생: 2026-05-08T05:28:12.905Z
- 최근 발생: 2026-05-08T05:28:12.905Z
동일 세션에서 floorplan 86223, 86230, 86242, 86243도 같은 "Floorplan not found" 오류를 경험했다. 동일 사용자(daniel.kim@cupix.com, team hoban)의 일괄 작업으로 추정되며, 처리 중 floorplan이 삭제된 것이 원인이다.
Root Cause Summary#
Floorplan 86241이 에이전트의 tile 처리 도중(약 3분 소요) 삭제되거나 접근 불가 상태로 변경되었다. tiling 완료 후 createTileUploadCredentials API를 호출했을 때 Tesla API가 HTTP 403 ARG10002 Floorplan not found를 반환했다. 이 에러가 floorplan-service.ts:107의 catch 블록에서 logger.error('FloorplanService::run | error', err)로 로깅될 때, winston의 format chain에서 Error 객체의 non-enumerable 속성(message, stack)이 JSON 직렬화 과정에서 누락되어 Datadog에 {} 로만 기록되었다. base/src/util/utils.ts:10에 이 문제를 해결하는 stringifyError 유틸이 이미 존재하지만 해당 코드 경로에서는 사용되지 않고 있다.
Technical Analysis#
Code Path#
- Entry point:
base-service.ts:107-108—runByMessages()catch 블록에서handlingMessageErrors(error)호출 또는FloorplanService::run내부 catch - Tile processing:
floorplan-service.ts:104—await this.tileFloorplan(cpFloorplan)성공 (05:28:12.864Z) - API call:
floorplan-service.ts:323—await this.cupixApi.floorplan.createTileUploadCredentials(cpFloorplan.id)실패 (403) - Error wrap:
floorplan-service.ts:325— 에러를 새 Error로 래핑하여 re-throw - Failure point:
floorplan-service.ts:107— catch 블록에서 에러 로깅 시{}출력
1. 메인 실행 흐름 — tiling 성공 후 uploadTile에서 실패:
try {
const exifTags = await this.getExifTags(cpFloorplan.localFilePath as string);
await this.updateFloorplanExif(targetId, exifTags);
const resolution = await this.getImageResolution(cpFloorplan.localFilePath);
await this.updateFloorplanResolution(targetId, resolution);
await this.tileFloorplan(cpFloorplan);
await this.uploadTile(cpFloorplan);
} catch (err) {
logger.error('FloorplanService::run | error', err);
}
line 105의 uploadTile에서 에러가 throw되면 line 107의 catch 블록에서 로깅한다. 이 catch는 에러를 삼키므로(handlingMessageErrors로 전파되지 않음), cleanup과 SQS 메시지 삭제가 정상 진행된다.
2. uploadTile — tile_upload_credentials API 호출 실패:
let s3Credentials: TESLA.UploadCredentials;
try {
s3Credentials = await this.cupixApi.floorplan.createTileUploadCredentials(cpFloorplan.id);
} catch (error) {
throw new Error(`FloorplanService::uploadTile - createTileUploadCredentials error - ${error}`);
}
createTileUploadCredentials가 HTTP 403을 받으면 TESLA API 클라이언트가 reject한다. catch에서 new Error(...) 로 래핑하여 re-throw한다. ${error}는 원본 에러의 .toString()을 호출하므로 "[object Object]"가 포함된다.
3. FloorplanApiModule — 생성된 API 호출:
createTileUploadCredentials = async (id: number): Promise<TESLA.UploadCredentials> => {
const api = await this.api();
const res = await api.createTileUploadCredentials(id, Fields.UploadCredentialFields);
return unwrapResult(res);
};
this.api()가 getApi를 호출하며, 내부에서 auth.checkToken()으로 토큰 유효성을 확인한 후 TESLA API 인스턴스를 반환한다.
4. CupixAuth::handleError — warn 로그 생성:
handleError = (ec: any): any => {
const response = ec && CPUtils.isJsonString(ec) ? JSON.parse(ec) : ec.response;
if (response != undefined) {
const statusCode = response.status || response.statusCode;
const requestUriHref = response.config?.url || response.request?.uri?.href;
const bodyResult = response.data?.result || response.body?.result;
if (statusCode != undefined && this.setErrorCode != undefined)
this.setErrorCode(ErrorCode.Tesla.statusCode(statusCode.toString()));
logger.warn('CupixAuth::handleError | Response statusCode: %d, requestUriHref: %s, body.result: %s',
statusCode, requestUriHref, JSON.stringify(bodyResult));
} else {
logger.warn('CupixAuth::handleError | Undefined response: %s',
JSON.stringify(ec, Object.getOwnPropertyNames(ec)));
}
return ec;
};
handleError는 에러 객체를 warn으로 로깅한 후 원본을 그대로 반환한다. 이 함수에서는 Object.getOwnPropertyNames를 사용하여 올바르게 직렬화하고 있다.
5. Logger JSON 포맷 — {} 직렬화 발생 지점:
new DailyRotateFile({
level: 'silly',
dirname: Environment.CPX_WORKSPACE_PATH,
filename: `${label}-json-%DATE%.log`,
format: winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.splat(),
errorSafeFormat(),
// ...
winston.format.printf((info) => {
const { level, message, label, timestamp, stack, ...rest } = info;
const cleanRest = filterNumericKeys(rest);
return JSON.stringify({
timestamp, level, label, message,
...(stack ? { stack } : {}),
...cleanRest
});
})
)
})
logger.error('FloorplanService::run | error', err) 호출 시:
format.splat()이 format string에%splaceholder가 없으므로err를 message에 interpolate하지 않음errorSafeFormat()의 splat 배열 처리(line 22-33)는err instanceof Error체크로 Error를{name, message, stack}으로 변환하지만, 이 변환된 값이 최종 JSON 출력의message필드에 반영되지 않음- Datadog는 JSON 로그의
message필드를 인덱싱하므로, 변환된 error 정보가 splat에만 남고 message 필드에는 포함되지 않는 edge case 발생
6. 이미 존재하는 올바른 유틸리티 함수 (미사용):
export const stringifyError = (error: any): string => {
return JSON.stringify(error, Object.getOwnPropertyNames(error));
};
이 stringifyError가 Error의 non-enumerable 속성까지 포함하여 직렬화하지만, floorplan-service.ts에서는 사용되지 않고 있다.
Log Evidence#
사용된 Datadog 쿼리:
service:cupixworks-any-floorplan-agent status:error @environment:production
Time range: 2026-05-08T04:28:12Z to 2026-05-08T06:58:12Z
service:cupixworks-any-floorplan-agent @environment:production
Time range: 2026-05-08T05:00:00Z to 2026-05-08T05:40:00Z
타겟 에러 로그 전체 속성:
{
"id": "AwAAAZ4GDslpoODD9QAAABhBWjRHRHRYM0FBQmJ6MUtUOHV3cy13QUMAAAAkMDE5ZTA2MTQtMTc1My00YmI2LTgwM2QtZGIwN2Q1MWI0ZjUyAAAzig",
"service": "cupixworks-any-floorplan-agent",
"timestamp": "2026-05-08T05:28:12.905Z",
"status": "error",
"message": "{}",
"host": "ip-10-1-109-82.us-west-2.compute.internal",
"meta.floorplan.id": 86241,
"meta.user.email": "daniel.kim@cupix.com",
"meta.team.domain": "hoban",
"meta.team.id": 1163,
"meta.session.id": "6db621d50c9bf84e1ca7cc6d9df5366e103f3ae4"
}
직전 warn 로그 (1ms 전):
2026-05-08T05:28:12.904Z [agent] [warn] CupixAuth::handleError | Response statusCode: 403, requestUriHref: http://api-tesla.cupix.internal/api/v1/floorplans/86241/tile_upload_credentials?fields[0]=aws_access_key_id&fields[1]=aws_secret_access_key&fields[2]=aws_session_token&fields[3]=bucket_region&fields[4]=bucket_name&fields[5]=basepath&fields[6]=acl&fields[7]=expires_at&fields[8]=endpoint, body.result: {"code":"ARG10002","type":"Cupix::Errors::NotFound","reason":"Floorplan not found","message":"Floorplan not found"}
실행 흐름 전체 (host ip-10-1-109-82, floorplan 86241):
05:25:30.341 [info] FloorplanService::translateFloorplan | source: /tmp/workspace/86241/source/35029.pdf, frame: 0, output: /tmp/workspace/86241/86241.png, width: 32768, height: 32768, dpi: 300, LOD: 7
05:27:28.772 [info] FloorplanService::tileFloorplan | begin
05:28:12.864 [info] FloorplanService::tileFloorplan | tiling done and now save to '/tmp/workspace/86241/tile'
05:28:12.904 [warn] CupixAuth::handleError | Response statusCode: 403, ...Floorplan not found
05:28:12.905 [error] {}
05:28:12.905 [info] BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/86241
05:28:13.412 [info] AwsQueueManager::deleteMessage | begin - queue url: https://sqs.us-west-2.amazonaws.com/002596530511/cupix-tesla-floorplan-agent-production
05:28:13.439 [info] AwsQueueManager::deleteMessage | end - message id: 80b6cda2-6255-471e-a775-99ae10e25f49
동일 세션 비교 — floorplan 86243 (정상 직렬화됨, handlingMessageErrors 경로):
05:27:25.851 [error] BaseService::handlingMessageErrors | Error and message object - {"code":"AGT1602","error":{"statusCode":403,"requestUriHref":"http://api-tesla.cupix.internal/api/v1/floorplans/86243/check_uploading?...","bodyResult":{"code":"ARG10002","type":"Cupix::Errors::NotFound","reason":"Floorplan not found","message":"Floorplan not found"},"modelId":86243},"sqsMessage":{"MessageId":"0e69cf87-5dbf-43ec-a842-60b40e2f688f","Attributes":{"ApproximateReceiveCount":"1"}}}
Floorplan 86243은 check_uploading 단계(tiling 전)에서 실패하여 BaseService::handlingMessageErrors 경로로 진입했고, 에러가 getApiErrorToDeleteMessage에 의해 plain object로 변환되어 정상 직렬화되었다. 반면 86241은 tiling 성공 후 uploadTile 내부에서 실패하여 FloorplanService::run의 로컬 catch(line 106-108)에서만 처리되어 {}로 직렬화되었다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Floorplan이 처리 중 삭제되어 tile_upload_credentials 호출 시 403 반환 | warn 로그에 ARG10002 Floorplan not found 명시, 동일 세션 5개 floorplan(86223, 86230, 86241, 86242, 86243) 동시 실패. 모두 같은 사용자/team |
— | Confirmed |
| H2 | Error 객체의 JSON 직렬화 결함으로 {} 출력 |
JS JSON.stringify(new Error(...)) → "{}"는 well-known behavior. base/src/util/utils.ts:10에 이를 해결하는 stringifyError 유틸 존재하지만 미사용. floorplan 86243과 비교 시 코드 경로 차이로 직렬화 결과 다름 |
— | Confirmed |
| H3 | 인증 토큰 만료로 403 발생 | 403 status code | 에러 코드가 ARG10002 (NotFound)이며 AUTH 계열 코드가 아님. tiling 전 API 호출(floorplan get, update 등)은 성공. checkToken이 getApi에서 호출되어 통과함 |
Rejected |
| H4 | 네트워크 일시 오류 또는 timeout | — | 응답 body에 명확한 JSON Floorplan not found 메시지 포함. 403은 서버가 정상 응답한 것. errno/syscall 에러 없음 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
floorplan-service.ts:107—logger.error('FloorplanService::run | error', err)에서 Error 정보가 정상 출력되도록 수정. 가장 간단한 방법은 format placeholder를 추가하는 것:logger.error('FloorplanService::run | error: %s', err instanceof Error ? err.message : JSON.stringify(err)). 또는 이미 존재하는stringifyError(err)사용.floorplan-service.ts:325—${error}대신error instanceof Error ? error.message : String(error)사용하여 "[object Object]" 방지.
단기 개선 (1주 이내)#
- agents 모노레포 전체에서
JSON.stringify(err)패턴을 검색하여stringifyError(err)또는 Error 직접 전달로 마이그레이션 (floorplan-service.ts:268, :273, :303 포함). - 에러 심각도 재평가: 외부 요인으로 리소스가 삭제된 경우
error가 아닌warn으로 다운그레이드. 에이전트 자체 결함이 아닌 운영 시나리오. - Floorplan 삭제 시 처리 중인 SQS 메시지를 graceful하게 처리하는 로직 검토.
장기 개선 (재발 방지)#
- ESLint 규칙 추가:
JSON.stringify(err)패턴 (Error를 직접 stringify하는 경우) 경고. cplogger.ts의errorSafeFormat이 format string 없이 Error를 두 번째 인자로 전달하는 패턴(logger.error('prefix', err))에서도 message 필드에 에러 정보가 포함되도록 개선.- API에서 floorplan 삭제 시 관련 SQS 메시지를 purge하는 lifecycle hook 구현.
Monitoring#
- 빈 객체 에러 감지:
service:cupixworks-any-floorplan-agent status:error @message:"{}" @environment:production
- Floorplan not found 빈도:
service:cupixworks-any-floorplan-agent "Floorplan not found" @environment:production
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial — 에러 로깅 개선은 1-2줄 수정. Floorplan 삭제 race condition은 운영 시나리오로 코드 버그가 아니며, 로깅 개선만으로도 디버깅 효율이 크게 향상됨.