edutap.observability-settings 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """How an eduTAP service reports what it is doing, and what it may say about people.
2
+
3
+ Three backends with three jobs: Sentry takes errors, an OTLP collector takes traces
4
+ and metrics, structlog produces the records that reach both. This package holds the
5
+ settings for all three and the one call that applies them.
6
+
7
+ It exists because the options are the point, not the code. Which Sentry options are
8
+ on decides whether a bearer token or a person's identifier leaves the process, and
9
+ each of them was chosen against a measurement rather than against a backend's
10
+ recommendation. Separated from the ``sentry_sdk.init()`` call that applies them those
11
+ measurements are worth nothing, which is why the settings live here and not in
12
+ ``edutap.data_models``.
13
+
14
+ What must never appear here is a dependency on any eduTAP service. This is installed
15
+ before a service resolves its own settings; it can know nothing about them.
16
+ """
17
+
18
+ from .install import install_observability, logfire_options, sentry_options
19
+ from .pseudonym import person_label, pseudonym
20
+ from .settings import OTLP_ENDPOINT_VARIABLE, ObservabilitySettings, PersonUidMode
21
+
22
+ __all__ = [
23
+ "OTLP_ENDPOINT_VARIABLE",
24
+ "ObservabilitySettings",
25
+ "PersonUidMode",
26
+ "install_observability",
27
+ "logfire_options",
28
+ "person_label",
29
+ "pseudonym",
30
+ "sentry_options",
31
+ ]
@@ -0,0 +1,153 @@
1
+ """Wiring the three backends the same way in every eduTAP service.
2
+
3
+ Three systems with three jobs, and the split is deliberate rather than historical:
4
+ **Sentry** takes errors, **the OTLP collector** takes traces and metrics, and
5
+ **structlog** produces the records that go to both. Nothing travels two paths --
6
+ Sentry's own tracing stays off, because the spans already go to the collector and
7
+ Bugsink, the tracker this estate runs, states that it does not support traces.
8
+
9
+ The options are returned by pure functions before they are applied. That is what lets
10
+ a test assert the exact set rather than assert that *something* was configured, and it
11
+ is the same shape ``edutap.data_provider`` uses, from whose observability design
12
+ record the Sentry options and their measurements are taken.
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ from typing import Any
18
+
19
+ import logfire
20
+ import sentry_sdk
21
+ import structlog
22
+
23
+ from .settings import OTLP_ENDPOINT_VARIABLE, ObservabilitySettings
24
+
25
+
26
+ def sentry_options(settings: ObservabilitySettings) -> dict[str, Any]:
27
+ """Return the options that decide what leaves the process.
28
+
29
+ Each contradicts the backend's own default, and each was chosen against a
30
+ measurement recorded in the data provider's observability design record.
31
+
32
+ ``include_local_variables=False`` -- with local variables on, a raw
33
+ ``Authorization`` header sits in the ASGI scope, which is a local in most frames of
34
+ an ASGI stack, and the bearer token then appears dozens of times in an event whose
35
+ rendered ``authorization`` header says ``[Filtered]``. Sentry's scrubber matches
36
+ key names; it does not walk a list of byte tuples.
37
+
38
+ ``max_request_body_size="never"`` -- for a service whose request body *is* the
39
+ identifying datum there is no partial version of this.
40
+
41
+ ``max_breadcrumbs=0`` -- Sentry's ``LoggingIntegration`` is on by default and turns
42
+ every WARNING/ERROR record into a breadcrumb carrying the record's formatted
43
+ message verbatim and unscrubbed, on a path none of the other options constrains.
44
+ A hook dropping only ``type == "log"`` breadcrumbs was the narrower alternative and
45
+ was rejected: it would need re-auditing against every breadcrumb-producing
46
+ integration added in the future, whereas turning them off closes the surface once.
47
+ The cost is real -- an event carries no timeline of what happened earlier -- and it
48
+ is the same trade already made for local variables and the request body.
49
+
50
+ ``Any`` rather than ``object`` in the return type, which is the one place this
51
+ package spends the escape hatch: the mapping is heterogeneous by nature and is
52
+ unpacked into a third-party signature, and ``object`` makes a type checker reject
53
+ every single key -- measured, 71 diagnostics for six options. Narrowing it to a
54
+ ``TypedDict`` would restate sentry-sdk's signature here and go stale with its next
55
+ release.
56
+ """
57
+ return {
58
+ "environment": settings.environment,
59
+ "traces_sample_rate": 0,
60
+ "send_default_pii": False,
61
+ "include_local_variables": False,
62
+ "max_request_body_size": "never",
63
+ "max_breadcrumbs": 0,
64
+ }
65
+
66
+
67
+ def logfire_options(
68
+ settings: ObservabilitySettings,
69
+ *,
70
+ service_name: str,
71
+ service_version: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Return how this service configures tracing, metrics and log export.
74
+
75
+ ``send_to_logfire=False`` is not a detail. The library defaults it to ``True``, so
76
+ leaving it unset would ship spans to a hosted third party the first time a token
77
+ happened to be present. This estate exports to its own collector.
78
+
79
+ The console stands in while there is no collector. Measured against logfire 4.40:
80
+ with ``send_to_logfire=False`` and no ``OTEL_EXPORTER_OTLP_ENDPOINT``, no exporter
81
+ is installed at all, so an instrumented service would be indistinguishable from an
82
+ uninstrumented one -- which is how instrumentation reaches production broken. Once
83
+ the endpoint is set the console stands down, because printing every span into the
84
+ container log is a development aid and not a production one.
85
+ """
86
+ exports_to_a_collector = bool(os.environ.get(OTLP_ENDPOINT_VARIABLE))
87
+ return {
88
+ "send_to_logfire": False,
89
+ "service_name": service_name,
90
+ "service_version": service_version,
91
+ "environment": settings.environment,
92
+ "console": False if exports_to_a_collector else logfire.ConsoleOptions(),
93
+ }
94
+
95
+
96
+ def install_observability(
97
+ settings: ObservabilitySettings | None = None,
98
+ *,
99
+ service_name: str,
100
+ service_version: str | None = None,
101
+ ) -> None:
102
+ """Install error reporting, tracing and structured logging for this process.
103
+
104
+ Call it first, before the service resolves the settings it needs to run, so that a
105
+ process refusing to start is reported rather than silently absent.
106
+
107
+ Both backends are opt-in by configuration and not by code: no DSN means no error
108
+ tracker, ``telemetry_enabled=False`` means no tracing. Structured logging is set up
109
+ either way -- a service without a collector still has to be readable.
110
+ """
111
+ settings = settings or ObservabilitySettings()
112
+
113
+ if settings.telemetry_enabled:
114
+ logfire.configure(
115
+ **logfire_options(settings, service_name=service_name, service_version=service_version)
116
+ )
117
+
118
+ _configure_structlog(settings)
119
+
120
+ if settings.sentry_dsn is not None:
121
+ sentry_sdk.init(dsn=settings.sentry_dsn.get_secret_value(), **sentry_options(settings))
122
+
123
+
124
+ def _configure_structlog(settings: ObservabilitySettings) -> None:
125
+ """Point structlog at the collector and render what is left as JSON.
126
+
127
+ ``logfire.StructlogProcessor`` is the bridge: it turns each event into a log
128
+ record on the OTel side, so a log line and the span it happened inside share a
129
+ trace id without the caller doing anything. It sits in the chain rather than at
130
+ the end, because the record still has to be rendered for the container log.
131
+
132
+ JSON rather than the developer console renderer: these lines are read out of
133
+ ``docker service logs`` and, later, out of the collector. A DLQ entry that cannot
134
+ be found by ``edutap-event-id`` is a DLQ entry nobody replays.
135
+ """
136
+ processors: list[structlog.typing.Processor] = [
137
+ structlog.contextvars.merge_contextvars,
138
+ structlog.processors.add_log_level,
139
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
140
+ structlog.processors.StackInfoRenderer(),
141
+ structlog.processors.format_exc_info,
142
+ ]
143
+ if settings.telemetry_enabled:
144
+ processors.append(logfire.StructlogProcessor())
145
+ processors.append(structlog.processors.JSONRenderer())
146
+
147
+ structlog.configure(
148
+ processors=processors,
149
+ wrapper_class=structlog.make_filtering_bound_logger(
150
+ logging.getLevelNamesMapping()[settings.log_level]
151
+ ),
152
+ cache_logger_on_first_use=True,
153
+ )
@@ -0,0 +1,57 @@
1
+ """Standing in for a person, or refusing to.
2
+
3
+ An error tracker is a machine that copies the state around a failure into a second
4
+ system. A ``person_uid`` at a university is not an opaque handle -- at the LMU it is
5
+ the LMU identifier with ``@lmu.de`` appended, and the circle able to turn that back
6
+ into a human being is far wider than the circle with directory administration rights.
7
+ Pseudonymised it is still personal data; the question a deployment answers here is not
8
+ *whether* but *who may see it*.
9
+ """
10
+
11
+ import hashlib
12
+ import hmac
13
+
14
+ from pydantic import SecretStr
15
+
16
+ from .settings import ObservabilitySettings
17
+
18
+
19
+ def pseudonym(person_uid: str, salt: SecretStr | None) -> str | None:
20
+ """Return a keyed, truncated stand-in for a person, or nothing without a key.
21
+
22
+ Keyed rather than a plain digest: a ``person_uid`` comes from a directory, so the
23
+ value space is small and enumerable, and an unsalted hash would be reversible by
24
+ anyone able to read the error tracker -- simply by hashing the directory.
25
+
26
+ Truncated to 12 hex characters, 48 bits: wide enough that two people in one
27
+ installation colliding is not a practical concern, short enough that the result
28
+ reads as a label rather than as an identifier worth storing.
29
+
30
+ An *empty* salt counts as no salt. ``compose.yml`` writes ``${VAR:-}``, which sets
31
+ a variable to the empty string rather than leaving it unset, and an HMAC under an
32
+ empty key is a well-defined, entirely unkeyed digest -- precisely the
33
+ hash-the-directory construction the key exists to prevent, and indistinguishable
34
+ from a real pseudonym by eye.
35
+ """
36
+ if salt is None or not salt.get_secret_value():
37
+ return None
38
+ digest = hmac.new(
39
+ salt.get_secret_value().encode("utf-8"),
40
+ person_uid.encode("utf-8"),
41
+ hashlib.sha256,
42
+ )
43
+ return digest.hexdigest()[:12]
44
+
45
+
46
+ def person_label(person_uid: str, settings: ObservabilitySettings) -> str | None:
47
+ """Return what may stand for this person in what leaves the process.
48
+
49
+ ``None`` means: attach nothing. Callers must treat that as an answer rather than
50
+ as a reason to fall back to the raw value -- a deployment that asked for
51
+ pseudonyms and forgot the key has to lose the datum, not publish it.
52
+ """
53
+ if settings.person_uid_mode == "omit":
54
+ return None
55
+ if settings.person_uid_mode == "plain":
56
+ return person_uid
57
+ return pseudonym(person_uid, settings.pseudonym_salt)
@@ -0,0 +1,72 @@
1
+ """Where a service reports to, and how much of a person may travel with it."""
2
+
3
+ from typing import Literal
4
+
5
+ from edutap.data_models.settings import ServiceSettings
6
+ from pydantic import SecretStr
7
+ from pydantic_settings import SettingsConfigDict
8
+
9
+ #: What may stand in for a ``person_uid`` in an error report or a span.
10
+ #:
11
+ #: ``pseudonym`` keeps correlation without identification, ``plain`` is the honest
12
+ #: choice where the tracker is read by exactly the people who may read the directory
13
+ #: anyway, and ``omit`` drops the datum entirely.
14
+ PersonUidMode = Literal["pseudonym", "plain", "omit"]
15
+
16
+
17
+ class ObservabilitySettings(ServiceSettings):
18
+ """Everything the reporting side of a service needs, and nothing it can fail on.
19
+
20
+ Inherits :class:`~edutap.data_models.settings.ServiceSettings` rather than sitting
21
+ beside it: ``environment``, ``telemetry_enabled`` and ``log_level`` are read here,
22
+ and a service should not have to assemble two objects to start reporting.
23
+
24
+ **No field is required.** Observability is installed *before* a service resolves
25
+ the settings it needs to run, so that a process refusing to start is reported
26
+ rather than silently absent. That ordering only holds if reading this can never
27
+ fail for want of a value. A value that is present but not a legal one still
28
+ fails -- ignoring it would decide in silence what leaves the process.
29
+
30
+ The prefix is ``EDUTAP_`` and deliberately not per-service. These fields are
31
+ defined by an eduTAP package, another university deploying them should not have to
32
+ learn an LMU name, and the one field whose value genuinely differs per service --
33
+ the Sentry DSN, which identifies one project -- differs by being set in that
34
+ service's own environment block, not by carrying a different name.
35
+ """
36
+
37
+ model_config = SettingsConfigDict(
38
+ env_prefix="EDUTAP_",
39
+ env_file=".env",
40
+ env_file_encoding="utf-8",
41
+ extra="ignore",
42
+ )
43
+
44
+ #: A DSN identifies one project, so every service gets its own rather than sharing
45
+ #: one. It is a write-only ingest key and thus low-sensitivity -- a plain
46
+ #: environment variable is enough and a Docker secret would be effort without
47
+ #: gain -- but it is still a credential, hence ``SecretStr``: ``BaseSettings``
48
+ #: prints every plain field verbatim, and a settings object reaches a log line
49
+ #: sooner or later. Unset means the integration stays off.
50
+ sentry_dsn: SecretStr | None = None
51
+
52
+ #: The HMAC key behind :func:`~edutap.observability_settings.pseudonym.pseudonym`.
53
+ #: Without it there is no pseudonym at all, rather than one computed from an empty
54
+ #: key -- that would be a plain digest of a small, enumerable value space, and
55
+ #: reversible by anyone able to hash the directory.
56
+ pseudonym_salt: SecretStr | None = None
57
+
58
+ #: Whether a person may be named in what leaves the process. The default is the
59
+ #: careful one, so that a deployment which has not thought about it does not
60
+ #: publish identifiers by omission.
61
+ person_uid_mode: PersonUidMode = "pseudonym"
62
+
63
+
64
+ #: Where the OTLP endpoint comes from -- the OpenTelemetry specification's own
65
+ #: variable, not a field of this class.
66
+ #:
67
+ #: An endpoint names a *receiver*, normally one per host or cluster, and which service
68
+ #: sent a span is carried in the resource attributes rather than in the address. A
69
+ #: field under ``EDUTAP_`` would be a second name for a value every OTel SDK already
70
+ #: reads by itself. This is the asymmetry with the Sentry DSN, which names a project
71
+ #: and therefore does belong to the service.
72
+ OTLP_ENDPOINT_VARIABLE = "OTEL_EXPORTER_OTLP_ENDPOINT"
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: edutap.observability_settings
3
+ Version: 0.1.3
4
+ Summary: Error reporting, tracing and structured logging, wired the same way in every eduTAP service
5
+ Project-URL: Source, https://github.com/edutap-collective/edutap.observability_settings
6
+ Project-URL: Issues, https://github.com/edutap-collective/edutap.observability_settings/issues
7
+ Author: eduTAP
8
+ License-Expression: EUPL-1.2
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: System :: Monitoring
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.13
18
+ Requires-Dist: edutap-data-models<0.3,>=0.2.1
19
+ Requires-Dist: logfire>=4.40
20
+ Requires-Dist: pydantic-settings>=2.4
21
+ Requires-Dist: pydantic>=2.8
22
+ Requires-Dist: sentry-sdk>=2.67
23
+ Requires-Dist: structlog>=25
24
+ Provides-Extra: dev
25
+ Requires-Dist: pdbp; extra == 'dev'
26
+ Requires-Dist: pytest>=8.2; extra == 'dev'
27
+ Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
28
+ Requires-Dist: ty; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: myst-parser; extra == 'docs'
31
+ Requires-Dist: sphinx>=8; extra == 'docs'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # edutap.observability_settings
35
+
36
+ Error reporting, tracing and structured logging, wired the same way in every eduTAP
37
+ service — and one decision written down: what a service may say about a person.
38
+
39
+ ## Why this exists
40
+
41
+ **Because the options are the point, not the code.** Three lines would start Sentry.
42
+ Which options those three lines carry decides whether a bearer token or a person's
43
+ identifier leaves the process, and each of the options below was chosen against a
44
+ measurement rather than against a backend's recommendation. Separated from the
45
+ `sentry_sdk.init()` call that applies them, those measurements are worth nothing —
46
+ which is why this is a package and not a paragraph in a README.
47
+
48
+ **Because a person's identifier is not an opaque handle.** At a university a
49
+ `person_uid` resolves to a human being for far more people than hold directory
50
+ administration rights; at the LMU it is the LMU identifier with `@lmu.de` appended.
51
+ Pseudonymised it remains personal data. What a deployment decides here is not
52
+ *whether* it is personal data but *who may see it*.
53
+
54
+ ## Usage
55
+
56
+ ```python
57
+ from edutap.observability_settings import install_observability, person_label
58
+
59
+ install_observability(service_name="lmu_edutap_worker") # first thing in main()
60
+
61
+ log.warning("no view for person", person=person_label(uid, settings))
62
+ ```
63
+
64
+ Call it before the service resolves the settings it needs to run, so that a process
65
+ refusing to start is reported rather than silently absent. Nothing here can fail for
66
+ want of a value.
67
+
68
+ ## Configuration
69
+
70
+ One prefix for the whole estate, `EDUTAP_`. These fields are defined by an eduTAP
71
+ package, and another university deploying them should not have to learn an LMU name.
72
+
73
+ | Variable | Default | Meaning |
74
+ |---|---|---|
75
+ | `EDUTAP_ENVIRONMENT` | `production` | Labels every event and every span. Unset must not masquerade as development. |
76
+ | `EDUTAP_TELEMETRY_ENABLED` | `true` | The deliberate off switch for tracing, metrics and log export. |
77
+ | `EDUTAP_LOG_LEVEL` | `INFO` | A closed set; a misspelled level fails at startup. |
78
+ | `EDUTAP_SENTRY_DSN` | unset | Unset means no error tracker. One project per service. |
79
+ | `EDUTAP_PSEUDONYM_SALT` | unset | The HMAC key behind the person pseudonym. Without it there is no pseudonym at all. |
80
+ | `EDUTAP_PERSON_UID_MODE` | `pseudonym` | `pseudonym` · `plain` · `omit` |
81
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | **Not** an `EDUTAP_` field — see below. |
82
+
83
+ ### Why the endpoint is not one of ours
84
+
85
+ A Sentry DSN names a **project**, so it belongs to the service and every service gets
86
+ its own. An OTLP endpoint names a **receiver**, normally one per host or cluster, and
87
+ which service sent a span rides in the resource attributes rather than in the address.
88
+ Every OpenTelemetry SDK already reads `OTEL_EXPORTER_OTLP_ENDPOINT` by itself; a
89
+ field under `EDUTAP_` would be a second name for the same value.
90
+
91
+ ```{note}
92
+ A shared prefix means shared *names*, not shared *values*. `EDUTAP_SENTRY_DSN` differs
93
+ per service by being set in that service's own compose `environment:` block. Only
94
+ genuinely stack-wide values belong in the shared `.env`.
95
+ ```
96
+
97
+ ### The three modes for a person
98
+
99
+ | Mode | What travels | When it is right |
100
+ |---|---|---|
101
+ | `pseudonym` | a keyed, 12-character label | The default. Correlation survives — forty errors about one person still read as one person — identification does not. |
102
+ | `plain` | the `person_uid` itself | Where the error tracker is read by exactly the people who may read the directory anyway. A configured decision, not an accident. |
103
+ | `omit` | nothing | Where the tracker is read more widely than the directory. |
104
+
105
+ `pseudonym` without `EDUTAP_PSEUDONYM_SALT` yields **nothing**, never the raw value: a
106
+ deployment that asked for pseudonyms and forgot the key has to lose the datum rather
107
+ than publish it. An empty salt counts as no salt — compose writes `${VAR:-}`, which
108
+ sets a variable to the empty string, and an HMAC under an empty key is a plain digest
109
+ of a small, enumerable value space and reversible by anyone who can hash the directory.
110
+
111
+ ## What the three backends do
112
+
113
+ Sentry takes errors. An OTLP collector takes traces and metrics. structlog produces
114
+ the records that reach both, bridged by `logfire.StructlogProcessor`, so a log line
115
+ and the span it happened inside share a trace id without the caller doing anything.
116
+
117
+ Nothing travels two paths: Sentry's own tracing stays off (`traces_sample_rate=0`),
118
+ because the spans already go to the collector and Bugsink — the tracker this estate
119
+ runs — states that it does not support traces.
120
+
121
+ ```{important}
122
+ `send_to_logfire=False` is not a detail. The library defaults it to `True`, so leaving
123
+ it unset would ship spans to a hosted third party the first time a token happened to
124
+ be present.
125
+ ```
126
+
127
+ While no collector exists, the console stands in. Measured against logfire 4.40: with
128
+ `send_to_logfire=False` and no `OTEL_EXPORTER_OTLP_ENDPOINT`, no exporter is installed
129
+ at all, so an instrumented service would be indistinguishable from an uninstrumented
130
+ one — which is how instrumentation reaches production broken. Once the endpoint is
131
+ set, the console stands down.
132
+
133
+ ## Development
134
+
135
+ ```shell
136
+ make venv
137
+ make lint
138
+ make test-local
139
+ ```
140
+
141
+ `tox` runs the suite across every supported Python version.
142
+
143
+ ## Design records
144
+
145
+ Under [`docs/superpowers/specs/`](docs/superpowers/specs/).
@@ -0,0 +1,8 @@
1
+ edutap/observability_settings/__init__.py,sha256=f4m0YUPEnwo7yP2zN4YgzYr3Q8fPQTwButFmO-VWPEM,1327
2
+ edutap/observability_settings/install.py,sha256=2dExNLEuQ5HW6kD1TKlnuaEgdG-rz_4ruvhmbQ_vDfY,6926
3
+ edutap/observability_settings/pseudonym.py,sha256=rgJcQbOcbdC-bDqlOm3iYEoT9nQA0w7QWaF7BWq-jok,2458
4
+ edutap/observability_settings/settings.py,sha256=TEfAQKoWmiVgfsUCZNn89JRtoOnFRkiwVd3AO6RWzn8,3653
5
+ edutap_observability_settings-0.1.3.dist-info/METADATA,sha256=u1BebmYrIcOakN0deuhb1rz_HqwGzHOpOm1zvPh2oDo,6663
6
+ edutap_observability_settings-0.1.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ edutap_observability_settings-0.1.3.dist-info/licenses/LICENSE,sha256=2UI9yv7y2X_WmlpE41IbjrcqqIrC3cAWMh40FWrSNLo,428
8
+ edutap_observability_settings-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,11 @@
1
+ SPDX-License-Identifier: EUPL-1.2
2
+
3
+ Licensed under the European Union Public Licence, Version 1.2 (the "EUPL").
4
+
5
+ You may obtain a copy of the licence at:
6
+
7
+ https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
11
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.