fix(mana-media): use prom-client directly instead of shared metrics package

mana-media uses NestJS 11 while shared-nestjs-metrics targets NestJS 10,
causing DynamicModule type incompatibility. Use prom-client directly with
a simple MetricsController to expose /metrics endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 11:06:09 +01:00
parent 677a499c93
commit 7910737dd9
12 changed files with 246 additions and 240 deletions

View file

@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
export interface FeedbackAnalysis {
title: string;
@ -10,26 +9,25 @@ export interface FeedbackAnalysis {
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private genAI: GoogleGenerativeAI | null = null;
private readonly manaLlmUrl: string | null = null;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('ai.geminiApiKey');
if (apiKey) {
this.genAI = new GoogleGenerativeAI(apiKey);
const url = this.configService.get<string>('MANA_LLM_URL');
if (url) {
this.manaLlmUrl = url;
this.logger.log(`AI service using mana-llm at ${url}`);
} else {
this.logger.warn('GOOGLE_GENAI_API_KEY not configured - AI features disabled');
this.logger.warn('MANA_LLM_URL not configured - AI features disabled');
}
}
async analyzeFeedback(feedbackText: string): Promise<FeedbackAnalysis> {
// Fallback if AI not available
if (!this.genAI) {
if (!this.manaLlmUrl) {
return this.fallbackAnalysis(feedbackText);
}
try {
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const prompt = `Analysiere dieses User-Feedback und generiere:
1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst
2. Eine passende Kategorie aus: bug, feature, improvement, question, other
@ -39,8 +37,23 @@ Feedback: "${feedbackText}"
Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text):
{"title": "...", "category": "..."}`;
const result = await model.generateContent(prompt);
const response = result.response.text().trim();
const result = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'ollama/gemma3:4b',
messages: [{ role: 'user', content: prompt }],
temperature: 0.3,
}),
signal: AbortSignal.timeout(30000),
});
if (!result.ok) {
throw new Error(`mana-llm error: ${result.status}`);
}
const data = await result.json();
const response = (data.choices?.[0]?.message?.content || '').trim();
// Parse JSON response - handle potential markdown code blocks
let jsonStr = response;

View file

@ -10,7 +10,6 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
COPY services/mana-media ./services/mana-media
# Install all dependencies
@ -42,8 +41,6 @@ COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/node
# Copy shared packages that are symlinked
COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-drizzle-config /app/packages/shared-drizzle-config
COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-nestjs-metrics /app/packages/shared-nestjs-metrics
# Copy built application
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/package.json ./

View file

@ -13,7 +13,6 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@manacore/shared-nestjs-metrics": "workspace:*",
"@nestjs/bullmq": "^11.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^3.3.0",
@ -26,6 +25,7 @@
"minio": "^8.0.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.0",
"prom-client": "^15.1.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.0",
"uuid": "^11.0.0",

View file

@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
import { DatabaseModule } from './db/database.module';
import { UploadModule } from './modules/upload/upload.module';
import { StorageModule } from './modules/storage/storage.module';
@ -9,16 +8,13 @@ import { ProcessModule } from './modules/process/process.module';
import { DeliveryModule } from './modules/delivery/delivery.module';
import { MatrixModule } from './modules/matrix/matrix.module';
import { HealthController } from './health.controller';
import { MetricsController } from './metrics.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
MetricsModule.register({
prefix: 'media_',
excludePaths: ['/health'],
}),
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST || 'localhost',
@ -33,6 +29,6 @@ import { HealthController } from './health.controller';
DeliveryModule,
MatrixModule,
],
controllers: [HealthController],
controllers: [HealthController, MetricsController],
})
export class AppModule {}

View file

@ -0,0 +1,31 @@
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client';
const register = new Registry();
register.setDefaultLabels({ service: 'mana-media' });
collectDefaultMetrics({ register, prefix: 'media_' });
export const httpRequestsTotal = new Counter({
name: 'media_http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [register],
});
export const httpRequestDuration = new Histogram({
name: 'media_http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'path', 'status'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register],
});
@Controller('metrics')
export class MetricsController {
@Get()
async getMetrics(@Res() res: Response) {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
}
}

View file

@ -1,6 +1,5 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { eq, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../database/database.module';
import { generations, projectItems, projects } from '../database/schema';
@ -13,25 +12,18 @@ type Database = PostgresJsDatabase<typeof schema>;
@Injectable()
export class GenerationService {
private readonly logger = new Logger(GenerationService.name);
private readonly openai: OpenAI;
private readonly manaLlmUrl: string;
private readonly model: string;
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private configService: ConfigService
) {
this.openai = new OpenAI({
apiKey: this.configService.get<string>('openai.apiKey'),
});
this.model = this.configService.get<string>('openai.model') || 'gpt-4o-mini';
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.model = this.configService.get<string>('openai.model') || 'ollama/gemma3:4b';
}
async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise<string> {
const apiKey = this.configService.get<string>('openai.apiKey');
if (!apiKey) {
throw new Error('OpenAI API key not configured');
}
// Get project info
const [project] = await this.db.select().from(projects).where(eq(projects.id, projectId));
if (!project) {
@ -46,7 +38,9 @@ export class GenerationService {
.orderBy(projectItems.createdAt);
if (items.length === 0) {
throw new Error('Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.');
throw new Error(
'Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'
);
}
// Build content summary
@ -76,17 +70,29 @@ Erstellt am: ${project.createdAt.toLocaleDateString('de-DE')}
Die folgenden Inhalte wurden während des Projekts gesammelt:`;
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: contentSummary },
],
temperature: 0.7,
max_tokens: 2000,
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: contentSummary },
],
temperature: 0.7,
max_tokens: 2000,
}),
signal: AbortSignal.timeout(120000),
});
const content = response.choices[0]?.message?.content || '';
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`mana-llm error: ${response.status} - ${errorText}`);
throw new Error(`LLM generation failed: ${response.status}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
// Save generation
await this.db.insert(generations).values({