Group pipeline runs into Langfuse sessions, tagged by trigger source

Each pipeline invocation (runAsync/searchAsync) now gets a fresh
sessionId, wrapped via the new Tracer.runWithSession() (implemented
with @langfuse/tracing's propagateAttributes, since v5 has no direct
sessionId/tags option on startObservation) so it shows up in
Langfuse's Sessions view. The session is also tagged with what
triggered the run: "cron", "api-list", "api-trigger", "api-search".

Verified: full test suite (79/79) passing, tsc/eslint clean, real
docker compose build + container smoke test against /health.
This commit is contained in:
2026-08-12 22:51:17 +02:00
parent b615f08039
commit e47c57b50d
10 changed files with 135 additions and 52 deletions

View File

@@ -40,7 +40,7 @@ export class RecommendationsController extends Controller {
: this.config.LLM_ENABLED;
try {
const recommendations = await this.pipeline.runAsync({ count, useLlm });
const recommendations = await this.pipeline.runAsync({ count, useLlm }, { trigger: 'api-list' });
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });
@@ -49,10 +49,10 @@ export class RecommendationsController extends Controller {
private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) {
try {
const recommendations = await this.pipeline.runAsync({
count: this.config.RECOMMEND_COUNT,
useLlm: this.config.LLM_ENABLED,
});
const recommendations = await this.pipeline.runAsync(
{ count: this.config.RECOMMEND_COUNT, useLlm: this.config.LLM_ENABLED },
{ trigger: 'api-trigger' }
);
if (this.config.RADARR_ENABLED) {
await this.pipeline.autoRequest(recommendations);
}
@@ -73,7 +73,7 @@ export class RecommendationsController extends Controller {
return;
}
try {
const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT);
const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT, 'api-search');
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });

View File

@@ -9,10 +9,10 @@ export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline
cron.schedule(config.CRON_SCHEDULE, async () => {
logger.info('Cron: running recommendation pipeline');
try {
const recommendations = await pipeline.runAsync({
count: config.RECOMMEND_COUNT,
useLlm: config.LLM_ENABLED,
});
const recommendations = await pipeline.runAsync(
{ count: config.RECOMMEND_COUNT, useLlm: config.LLM_ENABLED },
{ trigger: 'cron' }
);
if (config.RADARR_ENABLED) {
await pipeline.autoRequest(recommendations);
}

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { LlmPipeline } from '../../core/llm/LlmPipeline';
import type { RunContext } from '../../core/llm/RunContext';
import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient';
@@ -114,8 +115,16 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
}
}
async searchAsync(query: string, count: number): Promise<Recommendation[]> {
const trace = this.tracer.startTrace(`${this.name}-search`);
async searchAsync(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
const sessionId = randomUUID();
return this.tracer.runWithSession(
{ sessionId, tags: trigger ? [trigger] : undefined },
() => this.runSearch(query, count, trigger)
);
}
private async runSearch(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
const trace = this.tracer.startTrace(`${this.name}-search`, { trigger });
const ctx: RunContext = { trace, logger: this.logger };
try {
let library: Movie[] = [];

View File

@@ -1,9 +1,15 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { LangfuseSpanProcessor } from '@langfuse/otel';
import { startObservation } from '@langfuse/tracing';
import { propagateAttributes, startObservation } from '@langfuse/tracing';
import type { LangfuseGeneration, LangfuseSpan } from '@langfuse/tracing';
import { Tracer } from '../llm/Tracer';
import type { GenerationHandle, GenerationInput, GenerationOutput, TraceHandle } from '../llm/Tracer';
import type {
GenerationHandle,
GenerationInput,
GenerationOutput,
SessionInfo,
TraceHandle,
} from '../llm/Tracer';
export class LangfuseTracer extends Tracer {
private readonly processor: LangfuseSpanProcessor;
@@ -48,6 +54,10 @@ export class LangfuseTracer extends Tracer {
await this.processor.forceFlush();
}
runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T> {
return propagateAttributes({ sessionId: session.sessionId, tags: session.tags }, fn);
}
async shutdown(): Promise<void> {
await this.sdk.shutdown();
}

View File

@@ -1,8 +1,14 @@
import { randomUUID } from 'node:crypto';
import type { Logger } from '../logging/logger';
import type { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext';
import type { Tracer } from './Tracer';
export interface PipelineRunOptions {
/** What triggered this run (e.g. "cron", "api-list") — attached as a Langfuse tag. */
trigger?: string;
}
export abstract class LlmPipeline<TInput, TOutput> {
constructor(
protected readonly tracer: Tracer,
@@ -12,14 +18,20 @@ export abstract class LlmPipeline<TInput, TOutput> {
protected abstract readonly name: string;
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
async runAsync(input: TInput): Promise<TOutput> {
const trace = this.tracer.startTrace(this.name);
const ctx: RunContext = { trace, logger: this.logger };
try {
return await this.runInternal(input, ctx);
} finally {
await this.tracer.flush();
}
async runAsync(input: TInput, options: PipelineRunOptions = {}): Promise<TOutput> {
const sessionId = randomUUID();
return this.tracer.runWithSession(
{ sessionId, tags: options.trigger ? [options.trigger] : undefined },
async () => {
const trace = this.tracer.startTrace(this.name, { trigger: options.trigger });
const ctx: RunContext = { trace, logger: this.logger };
try {
return await this.runInternal(input, ctx);
} finally {
await this.tracer.flush();
}
}
);
}
protected runTool<TToolInput, TToolOutput>(

View File

@@ -20,7 +20,18 @@ export interface TraceHandle {
startGeneration(input: GenerationInput): GenerationHandle;
}
export interface SessionInfo {
sessionId: string;
tags?: string[];
}
export abstract class Tracer {
abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle;
abstract flush(): Promise<void>;
/**
* Runs `fn` with `session` (sessionId/tags) attached to every trace/span
* created inside it, so multiple runs can be grouped in the Langfuse UI.
*/
abstract runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T>;
}