Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Replace version in pyproject.toml
run: |
find . -name "pyproject.toml" -exec sed -i "s/^version = .*/version = \"${{ env.TAG }}\"/" {} +

- name: uv sync
run: uv sync --frozen --no-dev

Expand Down
25 changes: 17 additions & 8 deletions lkr/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,22 @@
__all__ = ["get_auth"]


def get_auth(ctx: typer.Context) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
lkr_ctx = get(ctx, ["obj", "ctx_lkr"])
if not lkr_ctx:
logger.error("No Looker context found")
raise typer.Exit(1)
elif lkr_ctx.use_sdk == "api_key":
def get_auth(ctx: typer.Context | LkrCtxObj) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
if isinstance(ctx, LkrCtxObj):
lkr_ctx = ctx
else:
lkr_ctx: LkrCtxObj | None = get(ctx, ["obj", "ctx_lkr"])
if not lkr_ctx:
logger.error("No Looker context found")
raise typer.Exit(1)
if lkr_ctx.use_sdk == "api_key" and lkr_ctx.api_key:
logger.info("Using API key authentication")
return ApiKeyAuth(lkr_ctx.api_key)
else:
return SqlLiteAuth(lkr_ctx)



class ApiKeyApiSettings(ApiSettings):
def __init__(self, api_key: LookerApiKey):
self.api_key = api_key
Expand Down Expand Up @@ -388,7 +392,7 @@ def get_current_instance(self) -> str | None:

def get_current_sdk(
self, prompt_refresh_invalid_token: bool = False
) -> Looker40SDK | None:
) -> Looker40SDK:
current_auth = self._get_current_auth()
if current_auth:
if not current_auth.valid_refresh_token:
Expand All @@ -399,6 +403,8 @@ def get_current_sdk(
else:
raise InvalidRefreshTokenError(current_auth.instance_name)



def refresh_current_token(token: Union[AccessToken, AuthToken]):
current_auth.set_token(self.conn, new_token=token, commit=True)

Expand All @@ -407,7 +413,10 @@ def refresh_current_token(token: Union[AccessToken, AuthToken]):
new_token_callback=refresh_current_token,
access_token=current_auth.to_access_token(),
)
return None

else:
logger.error("No current instance found, please login")
raise typer.Exit(1)

def delete_auth(self, instance_name: str):
self.conn.execute("DELETE FROM auth WHERE instance_name = ?", (instance_name,))
Expand Down
31 changes: 31 additions & 0 deletions lkr/embed/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Annotated

import typer

from lkr.auth_service import get_auth
from lkr.embed.observability.embed_server import run_server

__all__ = ["group"]

group = typer.Typer(name="embed", help="Embed commands for LookML Repository")


@group.command()
def observability(
ctx: typer.Context,
port: Annotated[
int, typer.Option("--port", help="Port to run the API on", envvar="PORT")
] = 8080,
log_event_prefix: Annotated[
str, typer.Option("--log-event-prefix", help="Prefix to use for the log events")
] = "lkr:embed:observability",
):
"""
Spin up an API to do embed observability. **Important:** requires a user with the `admin` role. This will create
an endpoint where you can send embed user properties to it, it will create the sso embed url, then load it up into a selenium
browser. The browser will log the user in using the sso embed url, then it will navigate to the dashboard and start the observability tests. It will log structured payloads
with metadata in each step. You can then ingest these using tools your of choice
"""
sdk = get_auth(ctx).get_current_sdk()

run_server(sdk=sdk, port=port, log_event_prefix=log_event_prefix)
8 changes: 8 additions & 0 deletions lkr/embed/observability/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

MAX_SESSION_LENGTH = 2592000

PERMISSIONS = [
"access_data",
"see_user_dashboards",
"see_lookml_dashboards"
]
53 changes: 53 additions & 0 deletions lkr/embed/observability/create_sso_embed_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Any, Dict, List, Optional, TypedDict
from uuid import uuid4

from looker_sdk.sdk.api40.methods import Looker40SDK
from looker_sdk.sdk.api40.models import EmbedSsoParams
from pydantic import BaseModel, Field

from lkr.embed.observability.constants import MAX_SESSION_LENGTH, PERMISSIONS


class CreateSSOEmbedUrlParams(BaseModel):
external_user_id: Optional[str] = Field(description="The external user id to create the sso embed url for", default_factory=lambda: f"embed-observability-{str(uuid4())}")
external_group_id: Optional[str] = None
models: Optional[List[str]] = Field(description="The models to create the sso embed url for")
permissions: Optional[List[str]] = Field(description="The permissions to create the sso embed url for")
dashboard: Optional[str] = Field(description="The dashboard to create the sso embed url for")
user_attribute: Optional[List[str]] = Field(description="The user attributes to create the sso embed url for")
user_timezone: Optional[str] = Field(description="The user timezone to create the sso embed url for")
group_ids: Optional[List[str]] = Field(description="The group ids to create the sso embed url for")
embed_domain: Optional[str] = Field(description="The embed domain to create the sso embed url for")

def to_embed_sso_params(self) -> EmbedSsoParams:
all_permissions = list(set(PERMISSIONS + (self.permissions or [])))
return EmbedSsoParams(
external_user_id=self.external_user_id,
external_group_id=self.external_group_id,
models=self.models,
permissions=all_permissions,
target_url=f"/embed/dashboards/{self.dashboard}",
session_length=MAX_SESSION_LENGTH,
force_logout_login=True,
first_name=None,
last_name=None,
user_timezone=None,
group_ids=None,
user_attributes=None,
embed_domain=None,

)
class URLResponse(TypedDict):
url: str
external_user_id: str

def create_sso_embed_url(sdk: Looker40SDK, *, data: Dict[str, Any]) -> URLResponse:
params = CreateSSOEmbedUrlParams(
external_user_id=data.get("external_user_id"),
external_group_id=data.get("external_group_id"),
models=data.get("models"),
permissions=data.get("permissions"),
dashboard=data.get("dashboard"),
)
sso_url = sdk.create_sso_embed_url(body=params.to_embed_sso_params())
return dict(url=sso_url.url, external_user_id=sso_url.external_user_id)
88 changes: 88 additions & 0 deletions lkr/embed/observability/embed_container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<title>Looker Embed Container</title>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
#completion-indicator {
display: none;
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
}
</style>
</head>
<body>
<iframe id="looker-iframe" src=""></iframe>
<script>
// Get the iframe URL from query parameters
const urlParams = new URLSearchParams(window.location.search);
const iframeUrl = urlParams.get('iframe_url');
const origin = new URL(iframeUrl).origin;
const dashboard = urlParams.get('dashboard_id');
const user_id = urlParams.get('user_id');
const task_id = urlParams.get('task_id');

// Set the iframe source
document.getElementById('looker-iframe').src = iframeUrl;

// Track which events we've received

const trackedEvents = new Set(['dashboard:loaded', 'dashboard:run:complete', 'dashboard:tile:complete', 'dashboard:run:start', 'dashboard:tile:start']);

// Listen for Looker embed events
window.addEventListener('message', function(event) {
if (event.origin !== origin) {
return;
}
const {type, ...data} = JSON.parse(event.data)
if (!trackedEvents.has(type)) {
return;
}
if (type) {
const now = new Date();
const eventData = {
event_type: type,
event_data: data,
timestamp: now.toISOString(),
duration_ms: now.getTime() - window.taskStart.getTime(),
dashboard_id: dashboard,
user_id: user_id,
task_id: task_id
};
// Send event data to the server
fetch('/log_event', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(eventData)
});

// Track received events
if (type === 'dashboard:run:complete') {
complete = document.createElement("div");
complete.id = "completion-indicator";
document.body.appendChild(complete);
}
}
});

// Store task start time
window.taskStart = new Date();
</script>
</body>
</html>
86 changes: 86 additions & 0 deletions lkr/embed/observability/embed_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

from looker_sdk.sdk.api40.methods import Looker40SDK

from lkr.embed.observability.create_sso_embed_url import create_sso_embed_url
from lkr.logging import logger, structured_logger


def log_event(prefix: str, event: str, **kwargs):
logger.info(f"{prefix}:{event}", **kwargs)

class EmbedHandler(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs):
self.sdk: Looker40SDK | None = None
super().__init__(*args, **kwargs)

def log_message(self, format, *args):
# Override to disable default server logging
pass

def do_GET(self):
path, *rest = self.path.split('?')
if path =='/':
# Serve the HTML file
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()

html_path = Path(__file__).parent / 'embed_container.html'
with open(html_path, 'rb') as f:
self.wfile.write(f.read())
else:
self.send_response(204)
self.end_headers()


def do_POST(self):
path, *rest = self.path.split('?')
if path == '/create_sso_embed_url':
if not self.sdk:
self.send_response(500)
self.end_headers()
return
else:
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data)

sso_url = create_sso_embed_url(self.sdk, data=data)
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(sso_url).encode('utf-8'))
if path == '/log_event':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
event_data = json.loads(post_data)

# # Log the event using structlog
# structured_logger.info(
# event_data['event_type'],
# timestamp=event_data['timestamp'],
# duration_ms=event_data['duration_ms'],
# dashboard_id=event_data['dashboard_id'],
# user_id=event_data['user_id'],
# **event_data['event_data'],
# )

self.send_response(200)
self.end_headers()
else:
self.send_response(404)
self.end_headers()

def run_server(*, sdk: Looker40SDK, port:int =3000, log_event_prefix="looker_embed_observability"):
class EmbedHandlerWithPrefix(EmbedHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log_event_prefix = log_event_prefix
self.sdk = sdk
server_address = ('', port)
httpd = HTTPServer(server_address, EmbedHandlerWithPrefix)
structured_logger.info(f"{log_event_prefix}:embed_server_started", port=port, embed_domain=f"http://localhost:{port}")
httpd.serve_forever()
2 changes: 1 addition & 1 deletion lkr/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@
def set_log_level(level: LogLevel):
"""Set the logging level for the application."""
logger.setLevel(getattr(logging, level.value))
structured_logger.setLevel(getattr(logging, level.value))
logging.getLogger("lkr.structured").setLevel(getattr(logging, level.value))
# Update requests_transport logger level based on the new level
requests_logger.setLevel(logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING)
3 changes: 2 additions & 1 deletion lkr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from lkr.auth.main import group as auth_group
from lkr.classes import LkrCtxObj
from lkr.logging import logger
from lkr.mcp.main import group as mcp_group
from lkr.types import LogLevel

app = typer.Typer(
name="lkr", help="LookML Repository CLI", add_completion=True, no_args_is_help=True
)

app.add_typer(auth_group, name="auth")

app.add_typer(mcp_group, name="mcp")

@app.callback()
def callback(
Expand Down
Loading