add logfire instrumentation to services and repository code

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2025-11-28 12:47:29 -06:00
parent 28cc5225a7
commit 0ca02a7ebe
20 changed files with 201 additions and 14 deletions
+1 -1
View File
@@ -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
]
+3 -1
View File
@@ -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)
+6 -8
View File
@@ -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
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+16
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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]],
@@ -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 = "/",
@@ -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,
+11
View File
@@ -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.
@@ -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,
):
@@ -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]:
@@ -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.
@@ -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.
Generated
+57 -2
View File
@@ -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"