diff --git a/pyproject.toml b/pyproject.toml index b51c326b..d3ffbc04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "python-dotenv>=1.1.0", "pytest-aio>=1.9.0", "aiofiles>=24.1.0", # Async file I/O - "logfire>=0.73.0", # Optional observability (disabled by default via config) + "logfire[fastapi]>=0.73.0", # Optional observability (disabled by default via config) "asyncpg>=0.30.0", "nest-asyncio>=1.6.0", # For Alembic migrations with Postgres ] diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index 07526c8e..1b79f421 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException from fastapi.exception_handlers import http_exception_handler from loguru import logger +import logfire from basic_memory import __version__ as version from basic_memory import db @@ -100,7 +101,8 @@ app.include_router(v2_project, prefix="/v2") app.include_router(project.project_resource_router) app.include_router(management.router) -# Auth routes are handled by FastMCP automatically when auth is enabled +# instrument app with logfire +logfire.instrument_fastapi(app) @app.exception_handler(Exception) diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 0f69ed8a..8a2f42a8 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -368,11 +368,10 @@ MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_proce async def get_file_service( project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep ) -> FileService: - logger.debug( - f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}" - ) file_service = FileService(project_config.home, markdown_processor) - logger.debug(f"Created FileService for project: {file_service} ") + logger.debug( + f"Created FileService for project: {project_config.name}, base_path: {project_config.home} " + ) return file_service @@ -382,11 +381,10 @@ FileServiceDep = Annotated[FileService, Depends(get_file_service)] async def get_file_service_v2( project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep ) -> FileService: - logger.debug( - f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}" - ) file_service = FileService(project_config.home, markdown_processor) - logger.debug(f"Created FileService for project: {file_service} ") + logger.debug( + f"Created FileService for project: {project_config.name}, base_path: {project_config.home}" + ) return file_service diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 5ee72d28..42d574ef 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import List, Optional, Sequence, Union, Any +import logfire from loguru import logger from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -32,6 +33,7 @@ class EntityRepository(Repository[Entity]): """ super().__init__(session_maker, Entity, project_id=project_id) + @logfire.instrument(record_return=True) async def get_by_id(self, entity_id: int) -> Optional[Entity]: """Get entity by numeric ID. @@ -44,6 +46,7 @@ class EntityRepository(Repository[Entity]): async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, entity_id) + @logfire.instrument(record_return=True) async def get_by_permalink(self, permalink: str) -> Optional[Entity]: """Get entity by permalink. @@ -53,6 +56,7 @@ class EntityRepository(Repository[Entity]): query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options()) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_by_title(self, title: str) -> Sequence[Entity]: """Get entity by title. @@ -63,6 +67,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]: """Get entity by file_path. @@ -76,6 +81,7 @@ class EntityRepository(Repository[Entity]): ) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_by_file_paths( self, session: AsyncSession, file_paths: Sequence[Union[Path, str]] ) -> List[Row[Any]]: @@ -104,6 +110,7 @@ class EntityRepository(Repository[Entity]): result = await session.execute(query) return list(result.all()) + @logfire.instrument(record_return=True) async def find_by_checksum(self, checksum: str) -> Sequence[Entity]: """Find entities with the given checksum. @@ -121,6 +128,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def find_by_checksums(self, checksums: Sequence[str]) -> Sequence[Entity]: """Find entities with any of the given checksums (batch query for move detection). @@ -149,6 +157,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool: """Delete entity with the provided file_path. @@ -169,6 +178,7 @@ class EntityRepository(Repository[Entity]): selectinload(Entity.incoming_relations).selectinload(Relation.to_entity), ] + @logfire.instrument(record_return=True) async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]: """Find multiple entities by their permalink. @@ -187,6 +197,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def upsert_entity(self, entity: Entity) -> Entity: """Insert or update entity using simple try/catch with database-level conflict resolution. @@ -288,6 +299,7 @@ class EntityRepository(Repository[Entity]): entity = await self._handle_permalink_conflict(entity, session) return entity + @logfire.instrument(record_return=True) async def get_all_file_paths(self) -> List[str]: """Get all file paths for this project - optimized for deletion detection. @@ -303,6 +315,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def get_distinct_directories(self) -> List[str]: """Extract unique directory paths from file_path column. @@ -331,6 +344,7 @@ class EntityRepository(Repository[Entity]): return sorted(directories) + @logfire.instrument(record_return=True) async def find_by_directory_prefix(self, directory_prefix: str) -> Sequence[Entity]: """Find entities whose file_path starts with the given directory prefix. @@ -363,6 +377,7 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity: """Handle permalink conflicts by generating a unique permalink.""" base_permalink = entity.permalink diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py index 5fb91595..6cae530c 100644 --- a/src/basic_memory/repository/observation_repository.py +++ b/src/basic_memory/repository/observation_repository.py @@ -2,6 +2,7 @@ from typing import Dict, List, Sequence +import logfire from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker @@ -21,30 +22,35 @@ class ObservationRepository(Repository[Observation]): """ super().__init__(session_maker, Observation, project_id=project_id) + @logfire.instrument(record_return=True) async def find_by_entity(self, entity_id: int) -> Sequence[Observation]: """Find all observations for a specific entity.""" query = select(Observation).filter(Observation.entity_id == entity_id) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def find_by_context(self, context: str) -> Sequence[Observation]: """Find observations with a specific context.""" query = select(Observation).filter(Observation.context == context) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def find_by_category(self, category: str) -> Sequence[Observation]: """Find observations with a specific context.""" query = select(Observation).filter(Observation.category == category) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def observation_categories(self) -> Sequence[str]: """Return a list of all observation categories.""" query = select(Observation.category).distinct() result = await self.execute_query(query, use_query_options=False) return result.scalars().all() + @logfire.instrument(record_return=True) async def find_by_entities(self, entity_ids: List[int]) -> Dict[int, List[Observation]]: """Find all observations for multiple entities in a single query. diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index 41eed484..b4992f28 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -5,6 +5,7 @@ import re from datetime import datetime from typing import List, Optional +import logfire from loguru import logger from sqlalchemy import text @@ -25,6 +26,7 @@ class PostgresSearchRepository(SearchRepositoryBase): - JSONB containment operators for metadata search """ + @logfire.instrument(record_return=True) async def init_search_index(self): """Create Postgres table with tsvector column and GIN indexes. @@ -145,6 +147,7 @@ class PostgresSearchRepository(SearchRepositoryBase): else: return cleaned_term + @logfire.instrument(record_return=True) async def search( self, search_text: Optional[str] = None, @@ -312,6 +315,7 @@ class PostgresSearchRepository(SearchRepositoryBase): return results + @logfire.instrument(record_return=True) async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple items in a single batch operation using UPSERT. diff --git a/src/basic_memory/repository/project_repository.py b/src/basic_memory/repository/project_repository.py index 4154292c..afd3b9ea 100644 --- a/src/basic_memory/repository/project_repository.py +++ b/src/basic_memory/repository/project_repository.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Optional, Sequence, Union +import logfire from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -22,6 +23,7 @@ class ProjectRepository(Repository[Project]): """Initialize with session maker.""" super().__init__(session_maker, Project) + @logfire.instrument(record_return=True) async def get_by_name(self, name: str) -> Optional[Project]: """Get project by name. @@ -31,6 +33,7 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.name == name) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_by_permalink(self, permalink: str) -> Optional[Project]: """Get project by permalink. @@ -40,6 +43,7 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.permalink == permalink) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]: """Get project by filesystem path. @@ -49,6 +53,7 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.path == Path(path).as_posix()) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_by_id(self, project_id: int) -> Optional[Project]: """Get project by numeric ID. @@ -61,17 +66,20 @@ class ProjectRepository(Repository[Project]): async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, project_id) + @logfire.instrument(record_return=True) async def get_default_project(self) -> Optional[Project]: """Get the default project (the one marked as is_default=True).""" query = self.select().where(Project.is_default.is_not(None)) return await self.find_one(query) + @logfire.instrument(record_return=True) async def get_active_projects(self) -> Sequence[Project]: """Get all active projects.""" query = self.select().where(Project.is_active == True) # noqa: E712 result = await self.execute_query(query) return list(result.scalars().all()) + @logfire.instrument(record_return=True) async def set_as_default(self, project_id: int) -> Optional[Project]: """Set a project as the default and unset previous default. @@ -96,6 +104,7 @@ class ProjectRepository(Repository[Project]): return target_project return None # pragma: no cover + @logfire.instrument(record_return=True) async def update_path(self, project_id: int, new_path: str) -> Optional[Project]: """Update project path. diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py index 913adb27..b2990973 100644 --- a/src/basic_memory/repository/relation_repository.py +++ b/src/basic_memory/repository/relation_repository.py @@ -1,9 +1,9 @@ """Repository for managing Relation objects.""" -from sqlalchemy import and_, delete from typing import Sequence, List, Optional -from sqlalchemy import select +import logfire +from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.orm import selectinload, aliased from sqlalchemy.orm.interfaces import LoaderOption @@ -25,6 +25,7 @@ class RelationRepository(Repository[Relation]): """ super().__init__(session_maker, Relation, project_id=project_id) + @logfire.instrument(record_return=True) async def find_relation( self, from_permalink: str, to_permalink: str, relation_type: str ) -> Optional[Relation]: @@ -46,18 +47,21 @@ class RelationRepository(Repository[Relation]): ) return await self.find_one(query) + @logfire.instrument(record_return=True) async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]: """Find all relations between two entities.""" query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id)) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def find_by_type(self, relation_type: str) -> Sequence[Relation]: """Find all relations of a specific type.""" query = select(Relation).filter(Relation.relation_type == relation_type) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def delete_outgoing_relations_from_entity(self, entity_id: int) -> None: """Delete outgoing relations for an entity. @@ -67,12 +71,14 @@ class RelationRepository(Repository[Relation]): async with db.scoped_session(self.session_maker) as session: await session.execute(delete(Relation).where(Relation.from_id == entity_id)) + @logfire.instrument(record_return=True) async def find_unresolved_relations(self) -> Sequence[Relation]: """Find all unresolved relations, where to_id is null.""" query = select(Relation).filter(Relation.to_id.is_(None)) result = await self.execute_query(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]: """Find unresolved relations for a specific entity. diff --git a/src/basic_memory/repository/repository.py b/src/basic_memory/repository/repository.py index 1cf69dd5..0720a284 100644 --- a/src/basic_memory/repository/repository.py +++ b/src/basic_memory/repository/repository.py @@ -2,6 +2,7 @@ from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict +import logfire from loguru import logger from sqlalchemy import ( select, @@ -84,6 +85,7 @@ class Repository[T: Base]: result = await session.execute(query) return result.scalars().one_or_none() + @logfire.instrument(record_return=True) async def select_by_ids(self, session: AsyncSession, ids: List[int]) -> Sequence[T]: """Select multiple entities by IDs using an existing session.""" query = ( @@ -95,6 +97,7 @@ class Repository[T: Base]: result = await session.execute(query) return result.scalars().all() + @logfire.instrument(record_return=True) async def add(self, model: T) -> T: """ Add a model to the repository. This will also add related objects @@ -121,6 +124,7 @@ class Repository[T: Base]: ) return found + @logfire.instrument(record_return=True) async def add_all(self, models: List[T]) -> Sequence[T]: """ Add a list of models to the repository. This will also add related objects @@ -152,6 +156,7 @@ class Repository[T: Base]: # Add project filter if applicable return self._add_project_filter(query) + @logfire.instrument(record_return=True) async def find_all( self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True ) -> Sequence[T]: @@ -183,6 +188,7 @@ class Repository[T: Base]: logger.debug(f"Found {len(items)} {self.Model.__name__} records") return items + @logfire.instrument(record_return=True) async def find_by_id(self, entity_id: int) -> Optional[T]: """Fetch an entity by its unique identifier.""" logger.debug(f"Finding {self.Model.__name__} by ID: {entity_id}") @@ -190,6 +196,7 @@ class Repository[T: Base]: async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, entity_id) + @logfire.instrument(record_return=True) async def find_by_ids(self, ids: List[int]) -> Sequence[T]: """Fetch multiple entities by their identifiers in a single query.""" logger.debug(f"Finding {self.Model.__name__} by IDs: {ids}") @@ -197,6 +204,7 @@ class Repository[T: Base]: async with db.scoped_session(self.session_maker) as session: return await self.select_by_ids(session, ids) + @logfire.instrument(record_return=True) async def find_one(self, query: Select[tuple[T]]) -> Optional[T]: """Execute a query and retrieve a single record.""" # add in load options @@ -210,6 +218,7 @@ class Repository[T: Base]: logger.trace(f"No {self.Model.__name__} found") return entity + @logfire.instrument(record_return=True) async def create(self, data: dict) -> T: """Create a new record from a model instance.""" logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}") @@ -241,6 +250,7 @@ class Repository[T: Base]: ) return return_instance + @logfire.instrument(record_return=True) async def create_all(self, data_list: List[dict]) -> Sequence[T]: """Create multiple records in a single transaction.""" logger.debug(f"Bulk creating {len(data_list)} {self.Model.__name__} instances") @@ -266,6 +276,7 @@ class Repository[T: Base]: return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue] + @logfire.instrument(record_return=True) async def update(self, entity_id: int, entity_data: dict | T) -> Optional[T]: """Update an entity with the given data.""" logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}") @@ -295,6 +306,7 @@ class Repository[T: Base]: logger.debug(f"No {self.Model.__name__} found to update: {entity_id}") return None + @logfire.instrument(record_return=True) async def delete(self, entity_id: int) -> bool: """Delete an entity from the database.""" logger.debug(f"Deleting {self.Model.__name__}: {entity_id}") @@ -312,6 +324,7 @@ class Repository[T: Base]: logger.debug(f"No {self.Model.__name__} found to delete: {entity_id}") return False + @logfire.instrument(record_return=True) async def delete_by_ids(self, ids: List[int]) -> int: """Delete records matching given IDs.""" logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}") @@ -327,6 +340,7 @@ class Repository[T: Base]: logger.debug(f"Deleted {result.rowcount} records") return result.rowcount + @logfire.instrument(record_return=True) async def delete_by_fields(self, **filters: Any) -> bool: """Delete records matching given field values.""" logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}") @@ -343,6 +357,7 @@ class Repository[T: Base]: logger.debug(f"Deleted {result.rowcount} records") return deleted + @logfire.instrument(record_return=True) async def count(self, query: Executable | None = None) -> int: """Count entities in the database table.""" async with db.scoped_session(self.session_maker) as session: @@ -364,6 +379,7 @@ class Repository[T: Base]: logger.debug(f"Counted {count} {self.Model.__name__} records") return count + @logfire.instrument(record_return=True) async def execute_query( self, query: Executable, diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index 0322a1c6..9f84d047 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from datetime import datetime from typing import Any, Dict, List, Optional +import logfire from loguru import logger from sqlalchemy import Executable, Result, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -102,6 +103,7 @@ class SearchRepositoryBase(ABC): """ pass + @logfire.instrument(record_return=True) async def index_item(self, search_index_row: SearchIndexRow) -> None: """Index or update a single item. @@ -145,6 +147,7 @@ class SearchRepositoryBase(ABC): logger.debug(f"indexed row {search_index_row}") await session.commit() + @logfire.instrument(record_return=True) async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple items in a single batch operation. @@ -192,6 +195,7 @@ class SearchRepositoryBase(ABC): logger.debug(f"Bulk indexed {len(search_index_rows)} rows") await session.commit() + @logfire.instrument(record_return=True) async def delete_by_entity_id(self, entity_id: int) -> None: """Delete all search index entries for an entity. @@ -206,6 +210,7 @@ class SearchRepositoryBase(ABC): ) await session.commit() + @logfire.instrument(record_return=True) async def delete_by_permalink(self, permalink: str) -> None: """Delete a search index entry by permalink. @@ -220,6 +225,7 @@ class SearchRepositoryBase(ABC): ) await session.commit() + @logfire.instrument(record_return=True) async def execute_query( self, query: Executable, diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 33cf38a5..3a00b92e 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -5,6 +5,7 @@ import re from datetime import datetime from typing import List, Optional +import logfire from loguru import logger from sqlalchemy import text @@ -25,6 +26,7 @@ class SQLiteSearchRepository(SearchRepositoryBase): - Prefix wildcard matching with * """ + @logfire.instrument(record_return=True) async def init_search_index(self): """Create FTS5 virtual table for search. @@ -279,6 +281,7 @@ class SQLiteSearchRepository(SearchRepositoryBase): # For non-Boolean queries, use the single term preparation logic return self._prepare_single_term(term, is_prefix) + @logfire.instrument(record_return=True) async def search( self, search_text: Optional[str] = None, diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index e8159ed3..26d4ae48 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import List, Optional, Tuple +import logfire from loguru import logger from sqlalchemy import text @@ -85,6 +86,7 @@ class ContextService: self.entity_repository = entity_repository self.observation_repository = observation_repository + @logfire.instrument(record_return=True) async def build_context( self, memory_url: Optional[MemoryUrl] = None, @@ -215,6 +217,7 @@ class ContextService: # Return the structured ContextResult return ContextResult(results=context_results, metadata=metadata) + @logfire.instrument(record_return=True) async def find_related( self, type_id_pairs: List[Tuple[str, int]], diff --git a/src/basic_memory/services/directory_service.py b/src/basic_memory/services/directory_service.py index cefdcf3d..fef93f59 100644 --- a/src/basic_memory/services/directory_service.py +++ b/src/basic_memory/services/directory_service.py @@ -4,6 +4,7 @@ import fnmatch import logging import os from typing import Dict, List, Optional, Sequence +import logfire from basic_memory.models import Entity from basic_memory.repository import EntityRepository @@ -23,6 +24,7 @@ class DirectoryService: """ self.entity_repository = entity_repository + @logfire.instrument(record_return=True) async def get_directory_tree(self) -> DirectoryNode: """Build a hierarchical directory tree from indexed files.""" @@ -90,6 +92,7 @@ class DirectoryService: # Return the root node with its children return root_node + @logfire.instrument(record_return=True) async def get_directory_structure(self) -> DirectoryNode: """Build a hierarchical directory structure without file details. @@ -133,6 +136,7 @@ class DirectoryService: return root_node + @logfire.instrument(record_return=True) async def list_directory( self, dir_name: str = "/", diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 1a6f7548..1df5af76 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -7,6 +7,7 @@ import frontmatter import yaml from loguru import logger from sqlalchemy.exc import IntegrityError +import logfire from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.file_utils import ( @@ -52,6 +53,7 @@ class EntityService(BaseService[EntityModel]): self.link_resolver = link_resolver self.app_config = app_config + @logfire.instrument(record_return=True) async def detect_file_path_conflicts( self, file_path: str, skip_check: bool = False ) -> List[Entity]: @@ -91,6 +93,7 @@ class EntityService(BaseService[EntityModel]): return conflicts + @logfire.instrument(record_return=True) async def resolve_permalink( self, file_path: Permalink | Path, @@ -149,6 +152,7 @@ class EntityService(BaseService[EntityModel]): return permalink + @logfire.instrument(record_return=True) async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]: """Create new entity or update existing one. Returns: (entity, is_new) where is_new is True if a new entity was created @@ -170,6 +174,7 @@ class EntityService(BaseService[EntityModel]): # Create new entity return await self.create_entity(schema), True + @logfire.instrument(record_return=True) async def create_entity(self, schema: EntitySchema) -> EntityModel: """Create a new entity and write to filesystem.""" logger.debug(f"Creating entity: {schema.title}") @@ -236,6 +241,7 @@ class EntityService(BaseService[EntityModel]): # Set final checksum to mark complete return await self.repository.update(entity.id, {"checksum": checksum}) + @logfire.instrument(record_return=True) async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel: """Update an entity's content and metadata.""" logger.debug( @@ -316,6 +322,7 @@ class EntityService(BaseService[EntityModel]): return entity + @logfire.instrument(record_return=True) async def delete_entity(self, permalink_or_id: str | int) -> bool: """Delete entity and its file.""" logger.debug(f"Deleting entity: {permalink_or_id}") @@ -345,6 +352,7 @@ class EntityService(BaseService[EntityModel]): logger.info(f"Entity not found: {permalink_or_id}") return True # Already deleted + @logfire.instrument(record_return=True) async def get_by_permalink(self, permalink: str) -> EntityModel: """Get entity by type and name combination.""" logger.debug(f"Getting entity by permalink: {permalink}") @@ -353,20 +361,24 @@ class EntityService(BaseService[EntityModel]): raise EntityNotFoundError(f"Entity not found: {permalink}") return db_entity + @logfire.instrument(record_return=True) async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]: """Get specific entities and their relationships.""" logger.debug(f"Getting entities: {ids}") return await self.repository.find_by_ids(ids) + @logfire.instrument(record_return=True) async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]: """Get specific nodes and their relationships.""" logger.debug(f"Getting entities permalinks: {permalinks}") return await self.repository.find_by_permalinks(permalinks) + @logfire.instrument(record_return=True) async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None: """Delete entity by file path.""" await self.repository.delete_by_file_path(str(file_path)) + @logfire.instrument(record_return=True) async def create_entity_from_markdown( self, file_path: Path, markdown: EntityMarkdown ) -> EntityModel: @@ -390,6 +402,7 @@ class EntityService(BaseService[EntityModel]): logger.error(f"Failed to upsert entity for {file_path}: {e}") raise EntityCreationError(f"Failed to create entity: {str(e)}") from e + @logfire.instrument(record_return=True) async def update_entity_and_observations( self, file_path: Path, markdown: EntityMarkdown ) -> EntityModel: @@ -430,6 +443,7 @@ class EntityService(BaseService[EntityModel]): db_entity, ) + @logfire.instrument(record_return=True) async def update_entity_relations( self, path: str, @@ -501,6 +515,7 @@ class EntityService(BaseService[EntityModel]): return await self.repository.get_by_file_path(path) + @logfire.instrument(record_return=True) async def edit_entity( self, identifier: str, @@ -558,6 +573,7 @@ class EntityService(BaseService[EntityModel]): return entity + @logfire.instrument(record_return=True) def apply_edit_operation( self, current_content: str, @@ -610,6 +626,7 @@ class EntityService(BaseService[EntityModel]): else: raise ValueError(f"Unsupported operation: {operation}") + @logfire.instrument(record_return=True) def replace_section_content( self, current_content: str, section_header: str, new_content: str ) -> str: @@ -729,6 +746,7 @@ class EntityService(BaseService[EntityModel]): return content + "\n" + current_content # pragma: no cover return content + current_content # pragma: no cover + @logfire.instrument(record_return=True) async def move_entity( self, identifier: str, diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index 4a00d510..c884f2c7 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Dict, Tuple, Union import aiofiles +import logfire import yaml from basic_memory import file_utils @@ -59,6 +60,7 @@ class FileService: """ return self.base_path / entity.file_path + @logfire.instrument(record_return=True) async def read_entity_content(self, entity: EntityModel) -> str: """Get entity's content without frontmatter or structured sections. @@ -77,6 +79,7 @@ class FileService: markdown = await self.markdown_processor.read_file(file_path) return markdown.content or "" + @logfire.instrument(record_return=True) async def delete_entity_file(self, entity: EntityModel) -> None: """Delete entity file from filesystem. @@ -89,6 +92,7 @@ class FileService: path = self.get_entity_path(entity) await self.delete_file(path) + @logfire.instrument(record_return=True) async def exists(self, path: FilePath) -> bool: """Check if file exists at the provided path. @@ -115,6 +119,7 @@ class FileService: logger.error("Failed to check file existence", path=str(path), error=str(e)) raise FileOperationError(f"Failed to check file existence: {e}") + @logfire.instrument(record_return=True) async def ensure_directory(self, path: FilePath) -> None: """Ensure directory exists, creating if necessary. @@ -142,6 +147,7 @@ class FileService: logger.error("Failed to create directory", path=str(path), error=str(e)) raise FileOperationError(f"Failed to create directory {path}: {e}") + @logfire.instrument(record_return=True) async def write_file(self, path: FilePath, content: str) -> str: """Write content to file and return checksum. @@ -185,6 +191,7 @@ class FileService: logger.exception("File write error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to write file: {e}") + @logfire.instrument(record_return=True) async def read_file_content(self, path: FilePath) -> str: """Read file content using true async I/O with aiofiles. @@ -220,6 +227,7 @@ class FileService: logger.exception("File read error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to read file: {e}") + @logfire.instrument(record_return=True) async def read_file(self, path: FilePath) -> Tuple[str, str]: """Read file and compute checksum using true async I/O. @@ -262,6 +270,7 @@ class FileService: logger.exception("File read error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to read file: {e}") + @logfire.instrument(record_return=True) async def delete_file(self, path: FilePath) -> None: """Delete file if it exists. @@ -276,6 +285,7 @@ class FileService: full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj full_path.unlink(missing_ok=True) + @logfire.instrument(record_return=True) async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str: """Update frontmatter fields in a file while preserving all content. @@ -344,6 +354,7 @@ class FileService: ) raise FileOperationError(f"Failed to update frontmatter: {e}") + @logfire.instrument(record_return=True) async def compute_checksum(self, path: FilePath) -> str: """Compute checksum for a file using true async I/O. diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index 9ed651ed..25f5c90b 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -7,6 +7,7 @@ to ensure consistent application startup across all entry points. import asyncio from pathlib import Path +import logfire from loguru import logger from basic_memory import db @@ -17,6 +18,7 @@ from basic_memory.repository import ( ) +@logfire.instrument(record_return=True) async def initialize_database(app_config: BasicMemoryConfig) -> None: """Initialize database with migrations handled automatically by get_or_create_db. @@ -38,6 +40,7 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None: # more specific error if the database is actually unusable +@logfire.instrument(record_return=True) async def reconcile_projects_with_config(app_config: BasicMemoryConfig): """Ensure all projects in config.json exist in the projects table and vice versa. @@ -71,6 +74,7 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig): logger.info("Continuing with initialization despite synchronization error") +@logfire.instrument(record_return=True) async def initialize_file_sync( app_config: BasicMemoryConfig, ): @@ -141,6 +145,7 @@ async def initialize_file_sync( return None +@logfire.instrument(record_return=True) async def initialize_app( app_config: BasicMemoryConfig, ): diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 837ad67c..09856262 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -2,6 +2,7 @@ from typing import Optional, Tuple +import logfire from loguru import logger from basic_memory.models import Entity @@ -26,6 +27,7 @@ class LinkResolver: self.entity_repository = entity_repository self.search_service = search_service + @logfire.instrument(record_return=True) async def resolve_link( self, link_text: str, use_search: bool = True, strict: bool = False ) -> Optional[Entity]: diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index ced78f8a..7a35697c 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -8,6 +8,7 @@ from datetime import datetime from pathlib import Path from typing import Dict, Optional, Sequence +import logfire from loguru import logger from sqlalchemy import text @@ -81,6 +82,7 @@ class ProjectService: """ return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project) + @logfire.instrument(record_return=True) async def list_projects(self) -> Sequence[Project]: """List all projects without loading entity relationships. @@ -90,6 +92,7 @@ class ProjectService: """ return await self.repository.find_all(use_load_options=False) + @logfire.instrument(record_return=True) async def get_project(self, name: str) -> Optional[Project]: """Get the file path for a project by name or permalink.""" return await self.repository.get_by_name(name) or await self.repository.get_by_permalink( @@ -130,6 +133,7 @@ class ProjectService: # Not nested in either direction return False + @logfire.instrument(record_return=True) async def add_project(self, name: str, path: str, set_default: bool = False) -> None: """Add a new project to the configuration and database. @@ -221,6 +225,7 @@ class ProjectService: logger.info(f"Project '{name}' added at {resolved_path}") + @logfire.instrument(record_return=True) async def remove_project(self, name: str, delete_notes: bool = False) -> None: """Remove a project from configuration and database. @@ -271,6 +276,7 @@ class ProjectService: except Exception as e: logger.warning(f"Failed to delete project directory {project_path}: {e}") + @logfire.instrument(record_return=True) async def set_default_project(self, name: str) -> None: """Set the default project in configuration and database. @@ -295,6 +301,7 @@ class ProjectService: logger.info(f"Project '{name}' set as default in configuration and database") + @logfire.instrument(record_return=True) async def _ensure_single_default_project(self) -> None: """Ensure only one project has is_default=True. @@ -336,6 +343,7 @@ class ProjectService: f"Set '{config_default}' as default project (was missing)" ) # pragma: no cover + @logfire.instrument(record_return=True) async def synchronize_projects(self) -> None: # pragma: no cover """Synchronize projects between database and configuration. @@ -420,6 +428,7 @@ class ProjectService: logger.info("Project synchronization complete") + @logfire.instrument(record_return=True) async def move_project(self, name: str, new_path: str) -> None: """Move a project to a new location. @@ -461,6 +470,7 @@ class ProjectService: self.config_manager.save_config(config) raise ValueError(f"Project '{name}' not found in database") + @logfire.instrument(record_return=True) async def update_project( # pragma: no cover self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None ) -> None: @@ -520,6 +530,7 @@ class ProjectService: f"Changed default project to '{new_default.name}' as '{name}' was deactivated" ) + @logfire.instrument(record_return=True) async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse: """Get comprehensive information about the specified Basic Memory project. @@ -587,6 +598,7 @@ class ProjectService: system=system, ) + @logfire.instrument(record_return=True) async def get_statistics(self, project_id: int) -> ProjectStatistics: """Get statistics about the specified project. @@ -703,6 +715,7 @@ class ProjectService: isolated_entities=isolated_count, ) + @logfire.instrument(record_return=True) async def get_activity_metrics(self, project_id: int) -> ActivityMetrics: """Get activity metrics for the specified project. diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index 3eefdafb..09e200ff 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -4,6 +4,7 @@ import ast from datetime import datetime from typing import List, Optional, Set +import logfire from dateparser import parse from fastapi import BackgroundTasks from loguru import logger @@ -35,10 +36,12 @@ class SearchService: self.entity_repository = entity_repository self.file_service = file_service + @logfire.instrument(record_return=True) async def init_search_index(self): """Create FTS5 virtual table if it doesn't exist.""" await self.repository.init_search_index() + @logfire.instrument(record_return=True) async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None: """Reindex all content from database.""" @@ -55,6 +58,7 @@ class SearchService: logger.info("Reindex complete") + @logfire.instrument(record_return=True) async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]: """Search across all indexed content. @@ -152,6 +156,7 @@ class SearchService: return [] # pragma: no cover + @logfire.instrument(record_return=True) async def index_entity( self, entity: Entity, @@ -163,6 +168,7 @@ class SearchService: else: await self.index_entity_data(entity, content) + @logfire.instrument(record_return=True) async def index_entity_data( self, entity: Entity, @@ -176,6 +182,7 @@ class SearchService: entity, content ) if entity.is_markdown else await self.index_entity_file(entity) + @logfire.instrument(record_return=True) async def index_entity_file( self, entity: Entity, @@ -198,6 +205,7 @@ class SearchService: ) ) + @logfire.instrument(record_return=True) async def index_entity_markdown( self, entity: Entity, @@ -335,14 +343,17 @@ class SearchService: # Batch insert all rows at once await self.repository.bulk_index_items(rows_to_index) + @logfire.instrument(record_return=True) async def delete_by_permalink(self, permalink: str): """Delete an item from the search index.""" await self.repository.delete_by_permalink(permalink) + @logfire.instrument(record_return=True) async def delete_by_entity_id(self, entity_id: int): """Delete an item from the search index.""" await self.repository.delete_by_entity_id(entity_id) + @logfire.instrument(record_return=True) async def handle_delete(self, entity: Entity): """Handle complete entity deletion from search index including observations and relations. diff --git a/uv.lock b/uv.lock index 9d5d73b4..aa0b7bb5 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + [[package]] name = "asttokens" version = "3.0.0" @@ -126,7 +135,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastmcp" }, { name = "greenlet" }, - { name = "logfire" }, + { name = "logfire", extra = ["fastapi"] }, { name = "loguru" }, { name = "markdown-it-py" }, { name = "mcp" }, @@ -171,7 +180,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, { name = "fastmcp", specifier = ">=2.10.2" }, { name = "greenlet", specifier = ">=3.1.1" }, - { name = "logfire", specifier = ">=0.73.0" }, + { name = "logfire", extras = ["fastapi"], specifier = ">=0.73.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "mcp", specifier = ">=1.2.0" }, @@ -897,6 +906,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/5f/0803848cc5ce524ff830e5f2a2f1400fd6ee72be705d87d0432cec42b1e4/logfire-4.13.2-py3-none-any.whl", hash = "sha256:887e99897a1818864aa5bfc595b02c93264ce23d1860866369eff6b6e2dde1c6", size = 228152, upload-time = "2025-10-13T16:17:50.641Z" }, ] +[package.optional-dependencies] +fastapi = [ + { name = "opentelemetry-instrumentation-fastapi" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1149,6 +1163,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/db/5ff1cd6c5ca1d12ecf1b73be16fbb2a8af2114ee46d4b0e6d4b23f4f4db7/opentelemetry_instrumentation-0.58b0-py3-none-any.whl", hash = "sha256:50f97ac03100676c9f7fc28197f8240c7290ca1baa12da8bfbb9a1de4f34cc45", size = 33019, upload-time = "2025-09-11T11:41:00.624Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/e2/03ff707d881d590c7adaed5e9d1979aed7e5e53fc1ed89035e5ed9f304af/opentelemetry_instrumentation_asgi-0.58b0.tar.gz", hash = "sha256:3ccc0c9c1c8c71e8d9da5945c6dcd9c0c8d147839f208536b7042c6dd98e65c9", size = 25116, upload-time = "2025-09-11T11:42:18.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/71/a00884c6655387c70070138acbf79a6616ad5d4489680f40708d75b598a7/opentelemetry_instrumentation_asgi-0.58b0-py3-none-any.whl", hash = "sha256:508a6d79e333d648d2afee0e140b6e80eb5d443be183be58e81d9ff88373168a", size = 16798, upload-time = "2025-09-11T11:41:08.105Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/09/4f8fcab834af6b403e5e2d94bdfb2d0835ba8cd1049bcc156995f47b65fb/opentelemetry_instrumentation_fastapi-0.58b0.tar.gz", hash = "sha256:03da470d694116a0a40f4e76319e42f3ff9efc49abf804b2acc2c07f96661497", size = 24598, upload-time = "2025-09-11T11:42:35.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/fb/82de06eba54e5cb979274f073065ebc374794853502d342b5155073d1194/opentelemetry_instrumentation_fastapi-0.58b0-py3-none-any.whl", hash = "sha256:d89bfec69c9ffc5d9f3fe58655d6660a66b2bca863b9132712c06edcde68b6fa", size = 13460, upload-time = "2025-09-11T11:41:28.507Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.37.0" @@ -1188,6 +1234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, ] +[[package]] +name = "opentelemetry-util-http" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/5f/02f31530faf50ef8a41ab34901c05cbbf8e9d76963ba2fb852b0b4065f4e/opentelemetry_util_http-0.58b0.tar.gz", hash = "sha256:de0154896c3472c6599311c83e0ecee856c4da1b17808d39fdc5cce5312e4d89", size = 9411, upload-time = "2025-09-11T11:43:05.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/a3/0a1430c42c6d34d8372a16c104e7408028f0c30270d8f3eb6cccf2e82934/opentelemetry_util_http-0.58b0-py3-none-any.whl", hash = "sha256:6c6b86762ed43025fbd593dc5f700ba0aa3e09711aedc36fd48a13b23d8cb1e7", size = 7652, upload-time = "2025-09-11T11:42:09.682Z" }, +] + [[package]] name = "packaging" version = "25.0"