diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx
index 70872af034763..d6a191513040a 100644
--- a/app/[[...path]]/page.tsx
+++ b/app/[[...path]]/page.tsx
@@ -131,6 +131,21 @@ export default async function Page(props: {params: Promise<{path?: string[]}>})
}
const {mdxSource, frontMatter} = doc;
+ // Fetch git metadata on-demand for this page only (faster in dev mode)
+ let gitMetadata = pageNode.frontmatter.gitMetadata;
+ if (!gitMetadata && pageNode.frontmatter.sourcePath?.startsWith('develop-docs/')) {
+ // In dev mode or if not cached, fetch git metadata for current page only
+ const {getGitMetadata} = await import('sentry-docs/utils/getGitMetadata');
+ const metadata = getGitMetadata(pageNode.frontmatter.sourcePath);
+ gitMetadata = metadata ?? undefined;
+ }
+
+ // Merge gitMetadata into frontMatter
+ const frontMatterWithGit = {
+ ...frontMatter,
+ gitMetadata,
+ };
+
// pass frontmatter tree into sidebar, rendered page + fm into middle, headers into toc
const pageType = (params.path?.[0] as PageType) || 'unknown';
return (
@@ -138,7 +153,7 @@ export default async function Page(props: {params: Promise<{path?: string[]}>})
diff --git a/src/components/docPage/index.tsx b/src/components/docPage/index.tsx
index 9390875a78d01..3e9171a864f26 100644
--- a/src/components/docPage/index.tsx
+++ b/src/components/docPage/index.tsx
@@ -16,6 +16,7 @@ import {CopyMarkdownButton} from '../copyMarkdownButton';
import {DocFeedback} from '../docFeedback';
import {GitHubCTA} from '../githubCTA';
import {Header} from '../header';
+import {LastUpdated} from '../lastUpdated';
import Mermaid from '../mermaid';
import {PaginationNav} from '../paginationNav';
import {PlatformSdkDetail} from '../platformSdkDetail';
@@ -94,6 +95,10 @@ export function DocPage({
{frontMatter.title}
+ {/* Show last updated info for develop-docs pages */}
+ {frontMatter.gitMetadata && (
+
+ )}
{frontMatter.description}
{/* This exact id is important for Algolia indexing */}
diff --git a/src/components/lastUpdated/index.tsx b/src/components/lastUpdated/index.tsx
new file mode 100644
index 0000000000000..e076aa679377b
--- /dev/null
+++ b/src/components/lastUpdated/index.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import Link from 'next/link';
+
+interface GitMetadata {
+ author: string;
+ commitHash: string;
+ timestamp: number;
+}
+
+interface LastUpdatedProps {
+ gitMetadata: GitMetadata;
+}
+
+/**
+ * Format a timestamp as a relative time string (e.g., "2 days ago")
+ */
+function formatRelativeTime(timestamp: number): string {
+ const now = Date.now();
+ const diff = now - timestamp * 1000; // timestamp is in seconds
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+ const months = Math.floor(days / 30);
+ const years = Math.floor(days / 365);
+
+ if (years > 0) {
+ return years === 1 ? '1 year ago' : `${years} years ago`;
+ }
+ if (months > 0) {
+ return months === 1 ? '1 month ago' : `${months} months ago`;
+ }
+ if (days > 0) {
+ return days === 1 ? '1 day ago' : `${days} days ago`;
+ }
+ if (hours > 0) {
+ return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
+ }
+ if (minutes > 0) {
+ return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
+ }
+ return 'just now';
+}
+
+/**
+ * Format a timestamp as a full date string for tooltip
+ */
+function formatFullDate(timestamp: number): string {
+ const date = new Date(timestamp * 1000);
+ return date.toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}
+
+/**
+ * Abbreviate a commit hash to first 7 characters
+ */
+function abbreviateHash(hash: string): string {
+ return hash.substring(0, 7);
+}
+
+export function LastUpdated({gitMetadata}: LastUpdatedProps) {
+ const {commitHash, author, timestamp} = gitMetadata;
+ const relativeTime = formatRelativeTime(timestamp);
+ const fullDate = formatFullDate(timestamp);
+ const abbreviatedHash = abbreviateHash(commitHash);
+ const commitUrl = `https://github.com/getsentry/sentry-docs/commit/${commitHash}`;
+
+ return (
+
+ {/* Text content */}
+
+ updated by
+ {author}
+ {/* Relative time with tooltip */}
+
+ {relativeTime}
+
+
+
+ {/* Commit link */}
+
+ •
+
+ #{abbreviatedHash}
+
+
+
+ );
+}
diff --git a/src/mdx.ts b/src/mdx.ts
index 611e2f212ead1..8f738926daa6f 100644
--- a/src/mdx.ts
+++ b/src/mdx.ts
@@ -252,10 +252,31 @@ export async function getDevDocsFrontMatterUncached(): Promise
{
const source = await readFile(file, 'utf8');
const {data: frontmatter} = matter(source);
+ const sourcePath = path.join(folder, fileName);
+
+ // In production builds, fetch git metadata for develop-docs pages only
+ // In development, skip this and fetch on-demand per page (faster dev server startup)
+ let gitMetadata: typeof frontmatter.gitMetadata = undefined;
+ if (
+ process.env.NODE_ENV !== 'development' &&
+ sourcePath.startsWith('develop-docs/')
+ ) {
+ const {getGitMetadata} = await import('./utils/getGitMetadata');
+ const metadata = getGitMetadata(sourcePath);
+ // Ensure we create a completely new object to avoid any reference sharing
+ gitMetadata = metadata ? {...metadata} : undefined;
+
+ // Log during build to debug Vercel issues
+ if (process.env.CI || process.env.VERCEL) {
+ console.log(`[BUILD] Git metadata for ${sourcePath}:`, gitMetadata);
+ }
+ }
+
return {
...(frontmatter as FrontMatter),
slug: fileName.replace(/\/index.mdx?$/, '').replace(/\.mdx?$/, ''),
- sourcePath: path.join(folder, fileName),
+ sourcePath,
+ gitMetadata,
};
},
{concurrency: FILE_CONCURRENCY_LIMIT}
diff --git a/src/types/frontmatter.ts b/src/types/frontmatter.ts
index a336bcefefe48..0791839148e89 100644
--- a/src/types/frontmatter.ts
+++ b/src/types/frontmatter.ts
@@ -35,15 +35,23 @@ export interface FrontMatter {
*/
fullWidth?: boolean;
+ /**
+ * Git metadata for the last commit & author that modified this file
+ */
+ gitMetadata?: {
+ author: string;
+ commitHash: string;
+ timestamp: number;
+ };
/**
* A list of keywords for indexing with search.
*/
keywords?: string[];
+
/**
* Set this to true to show a "new" badge next to the title in the sidebar
*/
new?: boolean;
-
/**
* The next page in the bottom pagination navigation.
*/
@@ -53,6 +61,7 @@ export interface FrontMatter {
* takes precedence over children when present
*/
next_steps?: string[];
+
/**
* Set this to true to disable indexing (robots, algolia) of this content.
*/
diff --git a/src/utils/getGitMetadata.ts b/src/utils/getGitMetadata.ts
new file mode 100644
index 0000000000000..07b3688227be4
--- /dev/null
+++ b/src/utils/getGitMetadata.ts
@@ -0,0 +1,70 @@
+import {execSync} from 'child_process';
+import path from 'path';
+
+export interface GitMetadata {
+ author: string;
+ commitHash: string;
+ timestamp: number;
+}
+
+// Cache to avoid repeated git calls during build
+const gitMetadataCache = new Map();
+
+/**
+ * Get git metadata for a file
+ * @param filePath - Path to the file relative to the repository root
+ * @returns Git metadata or null if unavailable
+ */
+export function getGitMetadata(filePath: string): GitMetadata | null {
+ // Check cache first
+ if (gitMetadataCache.has(filePath)) {
+ const cached = gitMetadataCache.get(filePath);
+ // Return a NEW copy to avoid reference sharing
+ return cached ? {...cached} : null;
+ }
+
+ try {
+ // Get commit hash, author name, and timestamp
+ const logOutput = execSync(`git log -1 --format="%H|%an|%at" -- "${filePath}"`, {
+ encoding: 'utf8',
+ cwd: path.resolve(process.cwd()),
+ stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr
+ }).trim();
+
+ // Log for debugging on Vercel
+ if (process.env.CI || process.env.VERCEL) {
+ console.log(`[getGitMetadata] File: ${filePath} -> Output: ${logOutput}`);
+ }
+
+ if (!logOutput) {
+ // No commits found for this file
+ gitMetadataCache.set(filePath, null);
+ return null;
+ }
+
+ const [commitHash, author, timestampStr] = logOutput.split('|');
+ const timestamp = parseInt(timestampStr, 10);
+
+ // Create a fresh object for each call to avoid reference sharing
+ const metadata: GitMetadata = {
+ commitHash,
+ author,
+ timestamp,
+ };
+
+ // Cache the metadata
+ gitMetadataCache.set(filePath, metadata);
+
+ // IMPORTANT: Return a NEW object, not the cached one
+ // This prevents all pages from sharing the same object reference
+ return {
+ commitHash: metadata.commitHash,
+ author: metadata.author,
+ timestamp: metadata.timestamp,
+ };
+ } catch (error) {
+ // Git command failed or file doesn't exist in git
+ gitMetadataCache.set(filePath, null);
+ return null;
+ }
+}