Prompt Templates and Managed Prompts
Track prompt templates in telemetry and use the separate authenticated managed-prompt API.
Prompt templates let you capture both the template structure and the runtime variable values for every LLM call, linking them to the span in the dashboard. This makes it possible to see exactly what was sent to the model for any trace, and compare how different variable values affect outputs.
SystemPromptTemplate and UserPromptTemplate
SystemPromptTemplate is for the system/instruction prompt. UserPromptTemplate is for the user/human turn. Both accept either a plain string or a list of message dicts, with {{variable}} placeholders.
from neatlogs import SystemPromptTemplate, UserPromptTemplate
system_template = SystemPromptTemplate([
{"role": "system", "content": "You are a {{role}} assistant. Context: {{context}}"},
])
user_template = UserPromptTemplate([
{"role": "user", "content": "{{question}}"},
])
PromptTemplate(and theprompt_template=keyword ontrace()) are kept as backward-compatible aliases forSystemPromptTemplate/system_prompt_template=. Both work; new code should use the canonical names.
Call .compile(**variables) to fill in the placeholders and get the rendered messages:
system_messages = system_template.compile(role="support", context=context_text)
# Returns: [{"role": "system", "content": "You are a support assistant. Context: ..."}].compile() also stores the template and variable values in context so Neatlogs can link them to the next LLM span.
Using Templates with trace(kind="LLM")
Wrap the .compile() call and the LLM invocation inside with neatlogs.trace(kind="LLM", system_prompt_template=...). Place this at the smallest unit containing the actual LLM call:
import neatlogs
from neatlogs import SystemPromptTemplate, UserPromptTemplate
from openai import OpenAI
system_template = SystemPromptTemplate([
{"role": "system", "content": "You are a {{role}} assistant. Context: {{context}}"},
])
user_template = UserPromptTemplate([
{"role": "user", "content": "{{question}}"},
])
@neatlogs.span(kind="AGENT")
def answer_agent(question: str, context: str) -> str:
with neatlogs.trace("answer_prompt", kind="LLM",
system_prompt_template=system_template,
user_prompt_template=user_template):
system_messages = system_template.compile(role="support", context=context)
user_messages = user_template.compile(question=question)
response = OpenAI().chat.completions.create(
model="gpt-4o",
messages=system_messages + user_messages,
)
return response.choices[0].message.contentManaged Prompts
For teams managing prompts centrally in the Neatlogs dashboard, Python and TypeScript expose authenticated prompt clients after init(). Managed-prompt reads and writes are product API requests; they are separate from trace/log export.
Unpinned prompt reads use a 60-second in-process cache by default. A fresh entry returns immediately. After expiry, the current SDKs use stale-while-revalidate: the caller receives the last usable value while one background refresh updates the cache. A refresh failure preserves that stale value and a later lookup retries. Request a specific version when a deployment must remain pinned.
Note:
neatlogs.init()must be called before module-level prompt methods (get_prompt,create_prompt, etc.). An explicitly constructedPromptClientorAsyncPromptClientuses its own API key and endpoint and does not requireinit().
Managed-prompt privacy, ownership, and retention
| Concern | Contract |
|---|---|
| Data path | Prompt content, messages, variables, labels, tags, and configuration are sent intentionally to the managed-prompt API. This is not OTLP telemetry. |
| Authentication | The SDK sends the project API key configured by init() as bearer/API-key authentication. Keep the key in a secret manager or environment variable, never in source. |
| Endpoint | The client uses the SDK base endpoint; hosted Neatlogs defaults to https://ingest.neatlogs.com. A custom/self-hosted endpoint applies to both telemetry and managed-prompt calls. |
| Ownership | A project API key is bound to one project. Prompt names and versions are resolved inside that project; a caller cannot select another project while using that key. Dashboard sessions are separately checked for project membership. |
| Retention | Versions are stored as project data. Deleting a managed-prompt version removes it from active reads by soft deletion; the API does not promise immediate physical erasure or a fixed purge interval. Organization/account deletion cascades project prompt data. Use your account's retention agreement for deletion-time guarantees. |
Telemetry mask= callbacks do not run on managed-prompt HTTP requests. Enabling local masking or server-side telemetry PII redaction does not mean prompt CRUD content was transformed. If content must be redacted before prompt storage, transform it explicitly before calling the prompt API. There is no prompt-transform option today.
import os
import neatlogs
neatlogs.init(
api_key=os.environ["NEATLOGS_API_KEY"],
)
prompt = neatlogs.get_prompt("support-system-prompt")import { getPrompt, init } from 'neatlogs';
await init({ apiKey: process.env.NEATLOGS_API_KEY });
const prompt = await getPrompt('support-system-prompt');Get and use a prompt
Fetch the prompt, wrap in a SystemPromptTemplate for tracking, and pass it to with neatlogs.trace(kind="LLM", system_prompt_template=...):
import neatlogs
from neatlogs import SystemPromptTemplate
@neatlogs.span(kind="AGENT")
def answer_agent(question: str, context: str) -> str:
# Default: most recently created version
prompt = neatlogs.get_prompt("support-system-prompt")
prompt_template = SystemPromptTemplate(prompt.content)
with neatlogs.trace("answer_prompt", kind="LLM", system_prompt_template=prompt_template):
messages = prompt.compile_messages({"context": context, "question": question})
response = openai_client.chat.completions.create(
model="gpt-4o",
messages=messages,
)
return response.choices[0].message.contentBy a specific label:
prompt = neatlogs.get_prompt("support-system-prompt", label="staging")By a specific version:
prompt = neatlogs.get_prompt("support-system-prompt", version=3)TypeScript uses the same selectors:
const staging = await getPrompt('support-system-prompt', { label: 'staging' });
const v3 = await getPrompt('support-system-prompt', { version: 3 });List
# All prompts
prompts = neatlogs.list_prompts()
# Filter by name or label
prompts = neatlogs.list_prompts(name="support-system-prompt", label="production")Create
# Text prompt
neatlogs.create_prompt(
name="greeting-prompt",
prompt="Hello {{name}}, how can I help you?",
labels=["production"],
)
# Chat prompt
neatlogs.create_prompt(
name="support-system-prompt",
prompt=[
{"role": "system", "content": "You are a {{role}} assistant."},
{"role": "user", "content": "{{question}}"},
],
type="chat",
labels=["production"],
)Promote a version (update labels)
Move a label to a specific version, for example to promote v3 to production:
neatlogs.update_prompt(
name="support-system-prompt",
version=3,
new_labels=["production"],
)Save as new version
neatlogs.save_as_version(
prompt_name="support-system-prompt",
messages=[
{"role": "system", "content": "You are an expert {{role}} assistant."},
],
labels=["staging"],
commit_message="More assertive tone",
)Delete a prompt version
Soft-deletes a specific version by name and version number:
neatlogs.delete_prompt("support-system-prompt", version=1)Remove a tag
Remove a tag from a specific prompt version:
neatlogs.remove_tag("support-system-prompt", version=3, tag="candidate")Async fetching
In an async application, use neatlogs.aget_prompt(...) — the coroutine version of get_prompt, with the same arguments — so you don't block the event loop on the fetch:
import neatlogs
prompt = await neatlogs.aget_prompt("support-system-prompt", label="production")For repeated fetches you can hold an AsyncPromptClient directly (call await client.close() when done). Direct clients use the credentials passed to their constructor. The module-level neatlogs.fetch_prompt(name, label=...) returns a raw CachedPrompt and requires init() first.
TypeScript cache controls
The module-level functions share the PromptClient created by init(). Construct a client directly when you need an explicit cache lifetime or a separate project:
import { PromptClient } from 'neatlogs';
const prompts = new PromptClient({
baseUrl: 'https://ingest.neatlogs.com',
apiKey: process.env.NEATLOGS_API_KEY!,
cacheTtlMs: 60_000,
});
try {
const current = await prompts.getPrompt('support-system-prompt');
const pinned = await prompts.getPrompt('support-system-prompt', { version: 3 });
} finally {
prompts.close();
}Concurrent TypeScript cache misses for the same name/label/version share one request. Successful create, update, save-as-version, delete, label, and tag mutations invalidate mutable cached selectors for that prompt; deletion also removes pinned versions. Prompt-client failures do not flush, shut down, or reconfigure the telemetry provider.
Best Practices
Set workflow_name for prompt versioning
When Neatlogs auto-captures prompts from LLM spans, it uses the workflow_name to scope prompt records:
{workflow_name}/{span_name}_promptThis ensures prompts from different workflows are tracked separately, with a clean version history per workflow. Set it in neatlogs.init():
neatlogs.init(workflow_name="customer-support")Use SystemPromptTemplate for full control
For the best prompt tracking experience, pass a SystemPromptTemplate to neatlogs.trace(). This gives you named prompts, variable tracking, and clear version history in the dashboard:
from neatlogs import SystemPromptTemplate
template = SystemPromptTemplate("You are a {{role}} assistant. Context: {{context}}")
with neatlogs.trace("support-agent", kind="LLM", system_prompt_template=template):
compiled = template.compile(role="support", context=context_text)
response = llm.invoke([HumanMessage(content=compiled)])