Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,30 @@ You can also run workspace-specific commands using:
npm run <script> -w @mocker/backend
npm run <script> -w @mocker/frontend
```

## Docker Logs

The backend writes structured JSON logs to stdout so deployed failures can be investigated directly with `docker logs`.

Each log entry includes:

- `timestamp`
- `level`
- `module`
- `message`
- `context`
- `error.name`
- `error.message`
- `error.stack`

Useful commands:

```bash
docker logs <container-name>
docker logs <container-name> | grep '"level":"error"'
docker logs <container-name> | grep '"module":"AIService"'
docker logs <container-name> | grep '"channelId":"C123"'
docker logs <container-name> | jq .
```

The `context` object is where request-specific identifiers live, such as `userId`, `teamId`, `channelId`, `itemId`, `symbol`, and prompt text. In production, start with `module` and `message`, then use `context` to isolate the failing request, and finally inspect `error.stack` for the root cause.
9 changes: 4 additions & 5 deletions packages/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,18 @@ COPY packages/backend/src ./packages/backend/src
# Build backend artifact inside Docker.
RUN npm ci \
&& npm run build:prod -w @mocker/backend \
&& npm prune --omit=dev \
&& mkdir -p /usr/src/app/images
&& npm prune --omit=dev

FROM gcr.io/distroless/nodejs20-debian12:nonroot AS release
ENV NODE_ENV=production \
PORT=80
PORT=80 \
IMAGE_DIR=/tmp/mocker-images

WORKDIR /usr/src/app

# Copy backend build artifacts and writable path from build stage.
# Copy backend build artifacts. Runtime-generated images are written under /tmp.
COPY --from=build --chown=65532:65532 /usr/src/app/packages/backend/dist ./dist
COPY --from=build --chown=65532:65532 /usr/src/app/node_modules ./node_modules
COPY --from=build --chown=65532:65532 --chmod=700 /usr/src/app/images ./images

EXPOSE 80

Expand Down
22 changes: 19 additions & 3 deletions packages/backend/src/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { suppressedMiddleware } from '../shared/middleware/suppression';
import { textMiddleware } from '../shared/middleware/textMiddleware';
import { aiMiddleware } from './middleware/aiMiddleware';
import type { SlashCommandRequest } from '../shared/models/slack/slack-models';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';

export const aiController: Router = express.Router();
Expand All @@ -21,7 +22,12 @@ aiController.post('/text', (req, res) => {
const { user_id, team_id, channel_id, text } = req.body;
res.status(200).send('Processing your request. Please be patient...');
void aiService.generateText(user_id, team_id, channel_id, text).catch((e) => {
aiLogger.error(e);
logError(aiLogger, 'Failed to generate AI text response', e, {
userId: user_id,
teamId: team_id,
channelId: channel_id,
prompt: text,
});
const errorMessage = `\`Sorry! Your request for ${text} failed. Please try again.\``;
void webService.sendEphemeral(channel_id, errorMessage, user_id);
return undefined;
Expand All @@ -32,7 +38,12 @@ aiController.post('/image', (req, res) => {
const { user_id, team_id, channel_id, text } = req.body;
res.status(200).send('Processing your request. Please be patient...');
void aiService.generateImage(user_id, team_id, channel_id, text).catch((e) => {
aiLogger.error(e);
logError(aiLogger, 'Failed to generate AI image response', e, {
userId: user_id,
teamId: team_id,
channelId: channel_id,
prompt: text,
});
const errorMessage = `\`Sorry! Your request for ${text} failed. Please try again.\``;
void webService.sendEphemeral(channel_id, errorMessage, user_id);
return undefined;
Expand All @@ -43,7 +54,12 @@ aiController.post('/prompt-with-history', (req, res) => {
const request: SlashCommandRequest = req.body;
res.status(200).send('Processing your request. Please be patient...');
void aiService.promptWithHistory(request).catch((e) => {
aiLogger.error(e);
logError(aiLogger, 'Failed to process AI prompt with history', e, {
userId: request.user_id,
teamId: request.team_id,
channelId: request.channel_id,
prompt: request.text,
});
const errorMessage = `\`Sorry! Your request for ${request.text} failed. Please try again.\``;
void webService.sendEphemeral(request.channel_id, errorMessage, request.user_id);
return undefined;
Expand Down
30 changes: 30 additions & 0 deletions packages/backend/src/ai/ai.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { AIService } from './ai.service';
import type { MessageWithName } from '../shared/models/message/message-with-name';
import { MOONBEAM_SLACK_ID } from './ai.constants';
Expand Down Expand Up @@ -155,6 +158,33 @@ describe('AIService', () => {
});
});

describe('writeToDiskAndReturnUrl', () => {
it('creates the image directory before writing the file', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-service-'));
const imageDir = path.join(tempRoot, 'nested', 'images');
const originalImageDir = process.env.IMAGE_DIR;

process.env.IMAGE_DIR = imageDir;

try {
const imageUrl = await aiService.writeToDiskAndReturnUrl(Buffer.from('png-bytes').toString('base64'));
const filename = imageUrl.split('/').pop();

expect(filename).toBeDefined();
expect(fs.existsSync(imageDir)).toBe(true);
expect(fs.readFileSync(path.join(imageDir, filename as string))).toEqual(Buffer.from('png-bytes'));
} finally {
if (originalImageDir === undefined) {
delete process.env.IMAGE_DIR;
} else {
process.env.IMAGE_DIR = originalImageDir;
}

fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});

describe('redeployMoonbeam', () => {
it('publishes deployment message with quote and image', async () => {
(aiService.openAi.responses.create as jest.Mock).mockResolvedValue({
Expand Down
104 changes: 80 additions & 24 deletions packages/backend/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './ai.constants';
import { MemoryPersistenceService } from './memory/memory.persistence.service';
import type { MemoryWithSlackId } from '../shared/db/models/Memory';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';
import { SlackService } from '../shared/services/slack/slack.service';
import { MuzzlePersistenceService } from '../muzzle/muzzle.persistence.service';
Expand Down Expand Up @@ -55,6 +56,8 @@ const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): str
return outputText?.trim();
};

const DEFAULT_IMAGE_DIR = path.join('/tmp', 'mocker-images');

export class AIService {
redis = new AIPersistenceService();
openAi = new OpenAI({
Expand Down Expand Up @@ -110,27 +113,35 @@ export class AIService {
}
})
.catch(async (e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to generate AI text response', e, {
userId,
teamId,
channelId,
prompt: text,
});
await this.redis.removeInflight(userId, teamId);
await this.redis.decrementDailyRequests(userId, teamId);
throw e;
});
}

public async writeToDiskAndReturnUrl(base64Image: string): Promise<string> {
const dir = process.env.IMAGE_DIR ? process.env.IMAGE_DIR : path.join(__dirname, '../../../images');
const dir = process.env.IMAGE_DIR ?? DEFAULT_IMAGE_DIR;
const filename = `${uuidv4()}.png`;
const filePath = path.join(dir, filename);
const base64Data = base64Image.replace(/^data:image\/png;base64,/, '');
return new Promise((resolve, reject) =>
fs.writeFile(filePath, base64Data, 'base64', (err) => {
if (err) {
this.aiServiceLogger.error('Error writing image to disk:', err);
reject(err);
}
resolve(`https://muzzle.lol/${filename}`);
}),
);

try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(filePath, base64Data, 'base64');
return `https://muzzle.lol/${filename}`;
} catch (error) {
logError(this.aiServiceLogger, 'Failed to write AI image to disk', error, {
imageDirectory: dir,
filePath,
});
throw error;
}
}

public async redeployMoonbeam(): Promise<void> {
Expand Down Expand Up @@ -191,8 +202,11 @@ export class AIService {
if (x) {
return this.writeToDiskAndReturnUrl(x);
} else {
this.aiServiceLogger.error(`No b64_json was returned for prompt: ${REDPLOY_MOONBEAM_IMAGE_PROMPT}`);
throw new Error(`No b64_json was returned for prompt: ${REDPLOY_MOONBEAM_IMAGE_PROMPT}`);
const error = new Error(`No b64_json was returned for prompt: ${REDPLOY_MOONBEAM_IMAGE_PROMPT}`);
logError(this.aiServiceLogger, 'Gemini redeploy image generation returned no image data', error, {
prompt: REDPLOY_MOONBEAM_IMAGE_PROMPT,
});
throw error;
}
});

Expand All @@ -217,7 +231,7 @@ export class AIService {
void this.webService.sendMessage('#muzzlefeedback', 'Moonbeam has been deployed.', blocks);
})
.catch((e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to redeploy Moonbeam assets', e);
});
}

Expand Down Expand Up @@ -274,15 +288,26 @@ export class AIService {
if (x) {
return this.writeToDiskAndReturnUrl(x);
} else {
this.aiServiceLogger.error(`No b64_json was returned for prompt: ${text}`);
throw new Error(`No b64_json was returned for prompt: ${text}`);
const error = new Error(`No b64_json was returned for prompt: ${text}`);
logError(this.aiServiceLogger, 'Gemini image generation returned no image data', error, {
userId,
teamId,
channelId: channel,
prompt: text,
});
throw error;
}
})
.then((imageUrl) => {
this.sendImage(imageUrl, userId, teamId, channel, text);
})
.catch(async (e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to generate AI image response', e, {
userId,
teamId,
channelId: channel,
prompt: text,
});
await this.redis.removeInflight(userId, teamId);
await this.redis.decrementDailyRequests(userId, teamId);
throw e;
Expand All @@ -296,7 +321,9 @@ export class AIService {
return extractAndParseOpenAiResponse(x);
})
.catch(async (e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to generate corpo-speak response', e, {
prompt: text,
});
throw e;
});
}
Expand Down Expand Up @@ -370,15 +397,25 @@ export class AIService {
});

this.webService.sendMessage(request.channel_id, request.text, blocks).catch((e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to send prompt-with-history response to Slack', e, {
userId: request.user_id,
teamId: request.team_id,
channelId: request.channel_id,
prompt: request.text,
});
void this.webService.sendMessage(
request.user_id,
'Sorry, unable to send the requested text to Slack. You have been credited for your Moon Token. Perhaps you were trying to send in a private channel? If so, invite @MoonBeam and try again.',
);
});
})
.catch(async (e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to process prompt with history', e, {
userId: request.user_id,
teamId: request.team_id,
channelId: request.channel_id,
prompt: request.text,
});
await this.redis.removeInflight(user_id, team_id);
await this.redis.decrementDailyRequests(user_id, team_id);
throw e;
Expand Down Expand Up @@ -420,7 +457,12 @@ export class AIService {
this.webService
.sendMessage(channelId, result, [{ type: 'markdown', text: result }])
.then(() => this.redis.setHasParticipated(teamId, channelId))
.catch((e) => this.aiServiceLogger.error('Error sending AI Participation message:', e));
.catch((e) =>
logError(this.aiServiceLogger, 'Failed to send AI participation message', e, {
teamId,
channelId,
}),
);

// Fire-and-forget: extract memories from this conversation
this.extractMemories(teamId, channelId, history, result, participantSlackIds).catch((e) =>
Expand All @@ -429,7 +471,11 @@ export class AIService {
}
})
.catch(async (e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to generate AI participation response', e, {
teamId,
channelId,
taggedMessage,
});
throw e;
})
.finally(() => {
Expand Down Expand Up @@ -655,7 +701,12 @@ export class AIService {
},
];
this.webService.sendMessage(channel, text, blocks).catch((e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to send generated AI image to Slack', e, {
userId,
teamId,
channelId: channel,
prompt: text,
});
void this.decrementDaiyRequests(userId, teamId);
void this.webService.sendMessage(
userId,
Expand Down Expand Up @@ -686,7 +737,12 @@ export class AIService {
});

this.webService.sendMessage(channelId, text, blocks).catch((e) => {
this.aiServiceLogger.error(e);
logError(this.aiServiceLogger, 'Failed to send generated AI text to Slack', e, {
userId,
teamId,
channelId,
prompt: query,
});
void this.decrementDaiyRequests(userId, teamId);
void this.webService.sendMessage(
userId,
Expand Down
Loading
Loading