Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions backend/src/baserow/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
InvalidClientSessionIdAPIException,
InvalidUndoRedoActionGroupIdAPIException,
)
from baserow.core.utils import get_user_remote_ip_address_from_request

UNTRUSTED_CLIENT_SESSION_ID_USER_ATTR = "untrusted_client_session_id"
UNDO_REDO_ACTION_GROUP_ID = "untrusted_client_action_group"
Expand Down Expand Up @@ -95,20 +96,6 @@ def _set_user_websocket_id(user, websocket_id):
user.web_socket_id = websocket_id


def get_user_remote_ip_address_from_request(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2.
# The first one is the original client IP.
return x_forwarded_for.split(",")[0].strip()

x_real_ip = request.META.get("HTTP_X_REAL_IP")
if x_real_ip:
return x_real_ip.strip()

return request.META.get("REMOTE_ADDR")


def set_user_remote_addr_ip_from_request(user, request):
ip_address = get_user_remote_ip_address_from_request(request)
set_user_remote_addr_ip(user, ip_address)
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/api/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
from baserow.api.schemas import get_error_schema
from baserow.api.sessions import (
get_untrusted_client_session_id,
get_user_remote_ip_address_from_request,
set_user_session_data_from_request,
)
from baserow.api.user.registries import user_data_registry
Expand Down Expand Up @@ -90,6 +89,7 @@
)
from baserow.core.user.handler import UserHandler
from baserow.core.user.utils import generate_session_tokens_for_user
from baserow.core.utils import get_user_remote_ip_address_from_request

from .errors import (
ERROR_ALREADY_EXISTS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2423,10 +2423,10 @@ def type_function(
func_call: BaserowFunctionCall[UnTyped],
arg: BaserowExpression[BaserowFormulaValidType],
) -> BaserowExpression[BaserowFormulaType]:
if BaserowGetFileCount().can_accept_arg(arg):
if BaserowGetFileCount().can_accept_arg(arg) and not arg.many:
return BaserowGetFileCount()(arg)

if isinstance(arg.expression_type, BaserowFormulaArrayType):
if isinstance(arg.expression_type, BaserowFormulaArrayType) and not arg.many:
return BaserowArrayLength()(arg)

return arg.expression_type.count(func_call, arg).with_valid_type(
Expand Down
16 changes: 15 additions & 1 deletion backend/src/baserow/core/telemetry/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import logging
import sys

from django.http import HttpRequest

from celery import signals
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.trace import Span

from baserow.core.psycopg import is_psycopg3
from baserow.core.telemetry.provider import DifferentSamplerPerLibraryTracerProvider
from baserow.core.telemetry.utils import BatchBaggageSpanProcessor, otel_is_enabled
from baserow.core.utils import get_user_remote_ip_address_from_request

OTEL_CLIENT_IP_ATTRIBUTE_NAMES = ("client.address", "net.peer.ip")


class LogGuruCompatibleLoggerHandler(LoggingHandler):
Expand Down Expand Up @@ -167,4 +173,12 @@ def _setup_standard_backend_instrumentation():
def _setup_django_process_instrumentation():
from opentelemetry.instrumentation.django import DjangoInstrumentor

DjangoInstrumentor().instrument()
DjangoInstrumentor().instrument(request_hook=_set_real_client_ip_on_request_span)


def _set_real_client_ip_on_request_span(span: Span, request: HttpRequest):
if span and span.is_recording():
ip_address = get_user_remote_ip_address_from_request(request)
if ip_address:
for attribute_name in OTEL_CLIENT_IP_ATTRIBUTE_NAMES:
span.set_attribute(attribute_name, ip_address)
41 changes: 40 additions & 1 deletion backend/src/baserow/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@
from fractions import Fraction
from itertools import chain, islice
from numbers import Number
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
Optional,
Set,
Tuple,
Type,
Union,
)

from django.conf import settings
from django.db import transaction
Expand All @@ -30,6 +41,9 @@

from .exceptions import CannotCalculateIntermediateOrder

if TYPE_CHECKING:
from django.http import HttpRequest

RE_ESCAPE_CHAR = re.compile(r"\\(\\)?")
RE_PROP_NAME = re.compile(
# Match anything that isn't a dot or bracket.
Expand Down Expand Up @@ -61,6 +75,31 @@ def flatten(nested_list: List[Any]):
]


def get_user_remote_ip_address_from_request(
request: HttpRequest,
) -> Optional[str]:
"""
Extracts the remote IP address of the user from the request. It checks for
X-Forwarded-For and X-Real-IP headers first (commonly set by proxies), and falls
back to the REMOTE_ADDR if neither is available.

:param request: The HTTP request object.
:return: The remote IP address as a string, or None if it is unavailable.
"""

x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2.
# The first one is the original client IP.
return x_forwarded_for.split(",")[0].strip()

x_real_ip = request.META.get("HTTP_X_REAL_IP")
if x_real_ip:
return x_real_ip.strip()

return request.META.get("REMOTE_ADDR")


def split_attrs_and_m2m_fields(
field_names: List[str], instance: Type[Model]
) -> Tuple[List[str], List[str]]:
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/throttling/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from rest_framework.throttling import SimpleRateThrottle

from baserow.api.exceptions import ThrottledAPIException
from baserow.api.sessions import get_user_remote_ip_address_from_request
from baserow.core.utils import get_user_remote_ip_address_from_request

from .blacklist import blacklist_ip, blacklist_token
from .exceptions import RateLimitExceededException
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/throttling/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
ThrottledAPIException,
api_exception_to_json_response,
)
from baserow.api.sessions import get_user_remote_ip_address_from_request
from baserow.core.utils import get_user_remote_ip_address_from_request
from baserow.throttling.handler import ConcurrentUserRequestsThrottle

from .blacklist import get_token_cooldown_time, is_ip_blacklisted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2340,3 +2340,116 @@ def test_count_formula_for_link_row_field_with_null_values(data_fixture):
)

assert getattr(row_a, formula_field.db_column) == 3


@pytest.mark.django_db
def test_count_formula_for_link_row_field_with_array_primary_field(data_fixture):
"""
A links to B, and B's primary field is a lookup array through a B to C link.
count(field('<link field>')) must count linked B rows, not each B row's inner
primary lookup array length (github issue #5276)
"""

user = data_fixture.create_user()

table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(user=user)
table_b_primary = table_b.field_set.get(primary=True)
table_b_primary.primary = False
table_b_primary.save()

table_c = data_fixture.create_database_table(user=user)
primary_c = data_fixture.create_text_field(
table=table_c, name="Field C", primary=True
)
link_b_to_c = data_fixture.create_link_row_field(
table=table_b,
link_row_table=table_c,
name="Link C",
)
data_fixture.create_formula_field(
table=table_b,
name="Primary lookup",
primary=True,
formula=f"lookup('{link_b_to_c.name}', '{primary_c.name}')",
)
count_formula = data_fixture.create_formula_field(
table=table_a,
name="Count",
formula=f"count(field('{link_a_to_b.name}'))",
)

row_handler = RowHandler()
rows_c = row_handler.force_create_rows(
user=user,
table=table_c,
rows_values=[{primary_c.db_column: "C1"}, {primary_c.db_column: "C2"}],
model=table_c.get_model(),
).created_rows
rows_b = row_handler.force_create_rows(
user=user,
table=table_b,
rows_values=[
{link_b_to_c.db_column: [rows_c[0].id]},
{link_b_to_c.db_column: [rows_c[1].id]},
],
model=table_b.get_model(),
).created_rows
row_a = row_handler.force_create_rows(
user=user,
table=table_a,
rows_values=[{link_a_to_b.db_column: [row.id for row in rows_b]}],
model=table_a.get_model(),
).created_rows[0]

assert getattr(row_a, count_formula.db_column) == 2


@pytest.mark.django_db
def test_count_formula_for_link_row_field_with_file_primary_field(data_fixture):
"""
A links to B, and B's primary field is a file field. count(field('<link field>'))
must count linked B rows, not each B row's primary file count.
"""

user = data_fixture.create_user()

table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(user=user)
table_b_primary = table_b.field_set.get(primary=True)
table_b_primary.primary = False
table_b_primary.save()

primary_b = data_fixture.create_file_field(
table=table_b, name="Files", primary=True
)
count_formula = data_fixture.create_formula_field(
table=table_a,
name="Count",
formula=f"count(field('{link_a_to_b.name}'))",
)

user_file_1 = data_fixture.create_user_file()
user_file_2 = data_fixture.create_user_file()
user_file_3 = data_fixture.create_user_file()
row_handler = RowHandler()
rows_b = row_handler.force_create_rows(
user=user,
table=table_b,
rows_values=[
{
primary_b.db_column: [
{"name": user_file_1.name},
{"name": user_file_2.name},
]
},
{primary_b.db_column: [{"name": user_file_3.name}]},
],
model=table_b.get_model(),
).created_rows
row_a = row_handler.force_create_rows(
user=user,
table=table_a,
rows_values=[{link_a_to_b.db_column: [row.id for row in rows_b]}],
model=table_a.get_model(),
).created_rows[0]

assert getattr(row_a, count_formula.db_column) == 2
47 changes: 47 additions & 0 deletions backend/tests/baserow/core/telemetry/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from unittest.mock import MagicMock, patch

from baserow.core.telemetry.telemetry import (
_set_real_client_ip_on_request_span,
_setup_django_process_instrumentation,
)


def test_set_real_client_ip_on_request_span_uses_forwarded_for(api_request_factory):
request = api_request_factory.get(
"/api/workspaces/",
REMOTE_ADDR="10.0.0.1",
HTTP_X_FORWARDED_FOR="203.0.113.50, 70.41.3.18",
)
span = MagicMock()
span.is_recording.return_value = True

_set_real_client_ip_on_request_span(span, request)

span.set_attribute.assert_any_call("client.address", "203.0.113.50")
span.set_attribute.assert_any_call("net.peer.ip", "203.0.113.50")
assert span.set_attribute.call_count == 2


def test_set_real_client_ip_on_request_span_falls_back_to_real_ip(api_request_factory):
request = api_request_factory.get(
"/api/workspaces/",
REMOTE_ADDR="10.0.0.1",
HTTP_X_REAL_IP="203.0.113.51",
)
span = MagicMock()
span.is_recording.return_value = True

_set_real_client_ip_on_request_span(span, request)

span.set_attribute.assert_any_call("client.address", "203.0.113.51")
span.set_attribute.assert_any_call("net.peer.ip", "203.0.113.51")
assert span.set_attribute.call_count == 2


def test_setup_django_process_instrumentation_registers_real_ip_request_hook():
with patch(
"opentelemetry.instrumentation.django.DjangoInstrumentor.instrument"
) as instrument:
_setup_django_process_instrumentation()

instrument.assert_called_once_with(request_hook=_set_real_client_ip_on_request_span)
6 changes: 3 additions & 3 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fixed count() returning an array instead of a number for link row fields whose linked primary field is an array formula or lookup.",
"issue_origin": "github",
"issue_number": 5276,
"domain": "database",
"bullet_points": [],
"created_at": "2026-04-29"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Use the real client IP address in OpenTelemetry request traces when proxy headers are present.",
"issue_origin": "github",
"issue_number": 5277,
"domain": "core",
"bullet_points": [],
"created_at": "2026-04-29"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fix custom code editors when deleting the first line",
"issue_origin": "github",
"issue_number": 5310,
"domain": "builder",
"bullet_points": [],
"created_at": "2026-05-05"
}
7 changes: 7 additions & 0 deletions changelog/entries/unreleased/feature/423_excel_import.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add support for importing Excel files into database tables",
"domain": "database",
"bullet_points": [],
"created_at": "2026-04-26"
}
Loading
Loading